在 Android 5.0
之後,隨着 Material Design
的提出,Android UI 設計語言可謂是提高了一大步,可是在國內其實並無獲得很大的推廣應用。前端
一是,要設計一個徹底遵循 Material Design
的App,UI設計師須要花費比較多的時間,開發者開發一樣須要花費更多的時間去實現,而國內的環境你們都知道的。git
二是,Material Design
有許多的過渡動畫和酷炫的效果,沒法避免的會有一些性能上的損耗。github
三是,國內對於App使用體驗上,雖然有了很大的提高,可是依然不如國外重視。canvas
不過,即便不能大規模的應用 Material Design
,也不妨礙咱們在一些特別的地方去實現一些效果,畢竟夢想仍是要有的嘛。bash
本文水波紋控件源碼:傳送門(Java 版和 Kotlin都有哦,歡迎享用,香的話給個Star呀🧡)markdown
一般狀況下,在實現一個 點擊 -> 選中
的時候,最簡單粗暴的方式就是點擊以後,給控件直接更換一個 背景色/背景圖
,可是這種效果每每是很是僵硬的,和用戶沒有很好的交互過程。ide
Material Design
就給出了很好的指導,好比點擊的時候控件有一個 z軸
的提高,控件背景色根據手指點擊的位置出現一個過渡的效果。函數
好比今天要介紹的這個水波紋選中效果。工具
有了這些以後,你會發現,整個點擊選中的體驗大幅提高,會讓人有一個絲絲順滑的感受,若是體驗足夠好,甚至會讓人點上癮,你會不自覺地在不一樣的按鈕來回點擊,體驗這種舒服的過渡感。oop
咱們知道在 Android 5.0 之後,要實現水波紋的效果點擊效果很簡單,只需配置 ripple
的 drawable
就能夠了。可是系統自帶的水波紋效果只是一個短暫的點擊響應過程,也就是最後水波紋消失了。
若是要讓水波紋擴散後保持住,好比實現一個水波紋選中效果,就沒法實現了。
原生的水波紋效果就不說了,相信你們都會。下邊就來看看如何經過自定View的方式實現一個水波紋選中的效果。
仔細看下這個點擊選中的過程,能夠拆分爲如下幾個過程:
z軸
,其實就是繪製陰影開始以前,來看看整個定製過程須要用到哪些工具:
以上,都是在自定義View中常常用到的工具。
這裏選擇 FrameLayout
做爲基礎 ViewGroup
是由於 若是繼承自 View
的話,這個控件就只能本身帶有水波紋效果,若是是個 ViewGroup
話,那麼就能夠包裹其餘的 View
實現總體的點擊效果,相似原生的 CardView
。
class RippleLayoutKtl: FrameLayout { // ...... constructor(context: Context) : super(context) { init(context, null) } constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { init(context, attrs) } constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init(context, attrs) } private fun init(context: Context, attrs: AttributeSet?) { // 初始化Scroller scroller = Scroller(context, DecelerateInterpolator(3f)) // 初始化水波紋畫筆 ripplePaint.color = rippleColor ripplePaint.style = Paint.Style.FILL ripplePaint.isAntiAlias = true // 初始化普通背景色畫筆 normalPaint.color = normalColor normalPaint.style = Paint.Style.FILL normalPaint.isAntiAlias = true // 初始化陰影畫筆 shadowPaint.color = Color.TRANSPARENT shadowPaint.style = Paint.Style.FILL shadowPaint.isAntiAlias = true //設置陰影,若是最右的參數color爲不透明的,則透明度由shadowPaint的alpha決定 shadowPaint.setShadowLayer(shadowSpace/5f*4f, 0f, 0f, shadowColor) // 設置pandding,爲繪製陰影留出空間 setPadding((shadowSpace + paddingLeft).toInt(), (shadowSpace + paddingTop).toInt(), (shadowSpace + paddingRight).toInt(), (shadowSpace + paddingBottom).toInt()) } override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_DOWN) { center.x = event.x center.y = event.y if (state == 0) { state = 1 expandRipple() } else { state = 0 shrinkRipple() } } return super.onTouchEvent(event) } // 擴散水波紋 private fun expandRipple() { drawing = true longestRadius = getLongestRadius() scroller.startScroll(0, 0, ceil(longestRadius).toInt(), 0, 1200) invalidate() } // 收縮水波紋 private fun shrinkRipple() { scroller.forceFinished(false) longestRadius = curRadius scroller.startScroll(curRadius.toInt(), 0, -curRadius.toInt(), 0, 800) drawing = true invalidate() } // 計算水波紋最長半徑 private fun getLongestRadius() : Float { return if (center.x > width / 2f) { // 計算觸摸點到左邊兩個頂點的距離 val leftTop = sqrt(center.x.pow(2f) + center.y.pow(2f)) val leftBottom = sqrt(center.x.pow(2f) + (height - center.y).pow(2f)) if (leftTop > leftBottom) leftTop else leftBottom } else { // 計算觸摸點到右邊兩個頂點的距離 val rightTop = sqrt((width - center.x).pow(2f) + center.y.pow(2f)) val rightBottom = sqrt((width - center.x).pow(2f) + (height - center.y).pow(2f)) if (rightTop > rightBottom) rightTop else rightBottom }.toFloat() } // ...... } 複製代碼
在 init
方法中,作了一些參數的初始化,好比 水波紋畫筆
、背景色畫筆
、陰影畫筆
,設置padding
等等,其中關於陰影和padding在後文再詳細講解。
上面的代碼中,重寫了 onTouchEvent
,並在接收到按下事件時,開始擴展水波或者收縮水波紋,而且記錄下手指按下的位置,這個位置就是水波紋的圓心,記錄爲 center.x
center.y
。
看一個簡單的 gif 動畫
這裏以控件中心爲例,同心圓不斷擴展,最後覆蓋整個控件。咱們知道,同心圓繪製的時候,超出控件的部分會被自動截斷,因此最後效果是這樣的
要想覆蓋整個控件,則
同心圓的最長半徑,等於觸摸點到控件
四個頂點
四個距離中最長的那個,而半徑的大小隻要利用勾股定理
就能夠計算出來。
這裏把觸摸點分爲在控件 左和右
兩種狀況,以下:
這樣,利用 勾股定理
分別計算 R1
和 R2
,而後取其中比較大的那個,就是咱們想要的最長半徑了。
具體計算請看以上 getLongestRadius
方法。
首先看下觸發水波紋擴散的方法:
class RippleLayoutKtl: FrameLayout { // ...... private fun expandRipple() { drawing = true longestRadius = getLongestRadius() scroller.startScroll(0, 0, ceil(longestRadius).toInt(), 0, 1200) invalidate() } // ...... } 複製代碼
在這個方法中,經過 getLongestRadius
使用上面介紹的計算方法,獲得了最長半徑, 並保存下來。
而後經過 Scrolle#startScroll
方法開啓一輪動畫。
關於動畫,實現的方法有不少,好比
ValueAnimator
、Handler
定時、甚至可使用線程的方式,可是在自定義View
中,一個更好的方法是使用Scroller
,它能夠結合View
自身的繪製流程,實現動畫的過程。
使用 Scroller
的典型方式,是經過 Scrolle#startScroll
來實現 View 位置的 平滑變換
,好比
//方法原型 //startScroll(int startX, int startY, int dx, int dy, int duration) //從座標點(0, 0),平移到座標點 (100, 0) scroller.startScroll(0, 0, 100, 0, 1200) 複製代碼
這裏咱們並不須要移動 View
,可是咱們能夠藉助 Scroller
的特色,來間接實現動畫。好比,咱們這裏
scroller.startScroll(0, 0, ceil(longestRadius).toInt(), 0, 1200) 複製代碼
藉助 x
的變化,轉化爲半徑 r
的變化,就是把 x
看成 r
使用。(固然了,你也可使用 y
相關的參數),這樣就能夠獲得從 0
到 longestRadius
遞增的同心圓半徑。
經過 scroller.startScroll
開啓了動畫,但是若是隻有這個方法,動畫是不會起做用的,由於還要和 View
的繪製流程做結合才行。
在 startScroll
後,調用了 invalidate()
這個方法,咱們知道,調用這個方法之後,系統會觸發 View的 draw
流程。
而在 draw
的過程當中,會調用 View
內部的一個方法 computeScroll
。這個方法是啓動動畫的關鍵,因此咱們要重寫這個方法,用來獲取當前動畫的進度,也就是當前繪製的同心圓的半徑。
class RippleLayoutKtl: FrameLayout { // ...... override fun computeScroll() { if (scroller.computeScrollOffset()) { updateChangingArgs() } else { stopChanging() } } private fun updateChangingArgs() { curRadius = scroller.currX.toFloat() var tmp = (curRadius / longestRadius * 255).toInt() if (state == 0) {// 提早隱藏,過渡比較天然 tmp -= 60 } if (tmp < 0) tmp = 0 if (tmp > 255) tmp = 255 ripplePaint.alpha = tmp shadowPaint.alpha = tmp invalidate() } private fun stopChanging() { drawing = false center.x = width.toFloat() / 2 center.y = height.toFloat() / 2 } // ...... 複製代碼
在 computeScroll
中經過 scroller.computeScrollOffset()
,這個方法會計算當前動畫執行的位置,而後返回是否應該繼續執行動畫。
經過判斷 scroller
是否已經執行完畢,返回 true
說明動畫還沒執行完,進入 updateChangingArgs
中更新動畫相關的參數:
// 獲取當前水波紋同心圓繪製半徑 curRadius = scroller.currX.toFloat() // 計算水波紋的半透值,逐漸上升,過渡更天然 var tmp = (curRadius / longestRadius * 255).toInt() 複製代碼
在 updateChangingArgs
的最後,又調用了 invalidate
,這就實現了一個死循環刷新
即:
invalidate->draw(onDraw/dispatchDraw)->computeScroll->invalidate
複製代碼
若是 scroller.computeScrollOffset()
返回 false
則結束動畫(再也不調用 invalidate
方法)。
動畫參數有了,剩下的就是繪製了。能夠有兩個選擇,一個是在 onDraw
方法中繪製,一個是在 dispatchDraw
中繪製。
若是選擇
onDraw
的話,要構造函數中調用一下這個方法setWillNotDraw(false)
,不然若是沒有背景色的話,ViewGroup
是不會調用onDraw
方法的。
這裏選擇 dispatchDraw
。
class RippleLayoutKtl: FrameLayout { // ...... override fun dispatchDraw(canvas: Canvas) { // 繪製默認背景色 canvas.drawPath(clipPath, normalPaint) // 繪製水波紋 canvas.drawCircle(center.x, center.y, curRadius, ripplePaint) // 繪製子View super.dispatchDraw(canvas) } // ...... } 複製代碼
繪製其實很簡單,就是在繪製子 View 以前,把背景色和水波紋繪製上去就完成了。
若是實現水波紋的話,只要上面的代碼就能夠了。可是,這樣效果仍是不夠細膩,咱們要給控件實現 圓角裁剪
和 陰影效果
。
在 Android 自定 View 中,實現裁剪有兩種方式:
clipRect
或 clipPath
等,指定裁剪範圍PorterDuff
混合模式能夠實現豐富的裁剪樣式。然而,經過 clipXXX
方式裁剪時,若是有圓角的狀況下會出現邊緣鋸齒,因此這裏 採用第二種方式 。
首先來看看 PorterDuffXfermode
顏色混合模式有哪些:
能夠看到,經過不一樣的模式,能夠控制下層 DST
和上層 SRC
兩層圖層造成不同的渲染效果。
本文采用的是 SRC_ATOP
,即在 SRC
和 DST
交匯的地方顯示上層的顏色,其餘位置通通不繪製。
class RippleLayoutKtl: FrameLayout { // ...... // 裁剪模式 private val xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP) override fun dispatchDraw(canvas: Canvas) { // 【1.1】新建圖層 val layerId = canvas.saveLayer(shadowRect, null, ALL_SAVE_FLAG) // 繪製默認背景色 canvas.drawPath(clipPath, normalPaint) // 【2.1】設置裁剪模式 ripplePaint.xfermode = xfermode // 繪製水波紋 canvas.drawCircle(center.x, center.y, curRadius, ripplePaint) // 【2.2】取消裁剪模式 ripplePaint.xfermode = null // 【1.2】將圖層繪製到canvas上 canvas.restoreToCount(layerId) // 繪製子View super.dispatchDraw(canvas) } // ...... } 複製代碼
這裏新增了4句代碼,分別兩兩對應
什麼做用呢?
系統畫布上,默認只有一個圖層,也就是說,全部的繪製都直接做用於這個圖層上。這時若是你想要一個乾淨的圖層來繪製一些東西,或者實現一些效果,就能夠經過 canvas.saveLayer
方法來新建一個 全透明
的圖層,而後在這個新圖層上渲染,最後經過 canvas.restoreToCount
將渲染好畫面,繪製到系統提供的默認圖層上。
這裏爲何要使用這個方法呢?
按照 PorterDuffXfermode
混合模式,應該是不須要新建一個圖層就能夠實現顏色混剪的。實驗發現,若是使用系統默認的圖層,沒法實現正常的裁剪。
這篇文章做者也遇到了相同的問題,通過的他實驗發現:
PorterDuffXfermode
顏色混合中的SRC
層是在設置xfermode
以前整個canvas
中的非透明像素點
。
也就是說,默認的圖層整個 canvas
都有顏色了,和 DST
混合以後,若是混合模式爲 SRC_ATOP
的話呈現的依然是整個 DST
,沒法實現裁剪效果。
也有人說是由於 SRC
和 DST
都要爲 Bitmap
,好比這篇文章。
本文驗證了第一種,發現是一致的,第二種就沒有嘗試了,有興趣的能夠去試驗一下。
因而這裏新建了一個新的 全透明的
圖層,因爲 canvas.drawPath(clipPath, normalPaint)
繪製的是一個帶有圓角的矩形,設置了 xfermode
模式爲 SRC_ATOP
,繪製的時候,水波紋同心圓
和 圓角矩形
交匯的地方就會顯示 水波紋的顏色
,其他透明的地方不顯示。
注:clipPath 在
onSizeChanged
方法中設置,後文會講解。
這兩句就是對應了設置和取消 裁剪模式
。
先繪製底部 SRC
(圓角矩形),而後設置水波紋畫筆的 xfermode
,接着繪製 DST
(水波紋),最後取消混合模式。
這樣,一個帶圓角的水波紋就實現了。
class RippleLayoutKtl: FrameLayout { // ...... // 混合裁剪模式 private val xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP) override fun dispatchDraw(canvas: Canvas) { // 【1】開啓軟件渲染模式 setLayerType(View.LAYER_TYPE_SOFTWARE, null) // 【2】繪製陰影 canvas.drawRoundRect(shadowRect, radius, radius, shadowPaint) // 設置混合裁剪模式 val layerId = canvas.saveLayer(shadowRect, null, ALL_SAVE_FLAG) // 繪製默認背景色 canvas.drawPath(clipPath, normalPaint) // 設置裁剪模式 ripplePaint.xfermode = xfermode // 繪製水波紋 canvas.drawCircle(center.x, center.y, curRadius, ripplePaint) // 取消裁剪模式 ripplePaint.xfermode = null // 將畫布繪製到canvas上 canvas.restoreToCount(layerId) // 繪製子View super.dispatchDraw(canvas) } // ...... } 複製代碼
繪製陰影和很是簡單,兩句代碼就能夠實現:
canvas.drawRoundRect
繪製一個矩形。你確定會奇怪,爲何繪製一個圓角矩形就能夠實現陰影了?
還記得前文初始化控件 init
方法中提到的設置 陰影畫筆
,設置padding
嗎?從新看下代碼:
private fun init(context: Context, attrs: AttributeSet?) { // ...... shadowPaint.color = Color.TRANSPARENT shadowPaint.style = Paint.Style.FILL shadowPaint.isAntiAlias = true //設置陰影,若是最右的參數color爲不透明的,則透明度由shadowPaint的alpha決定 shadowPaint.setShadowLayer(shadowSpace/5f*4f, 0f, 0f, shadowColor) setPadding((shadowSpace + paddingLeft).toInt(), (shadowSpace + paddingTop).toInt(), (shadowSpace + paddingRight).toInt(), (shadowSpace + paddingBottom).toInt()) } 複製代碼
有兩種方法:
/** * radius: 爲陰影半徑,就是上邊繪製圓角矩形後,陰影超出矩形的距離 * dx/dy: 陰影的偏移距離 * shadowColor: 陰影的顏色。color爲不透明時,透明度由shadowPaint的alpha決定,不然由shadowColor決定。 */ public void setShadowLayer(float radius, float dx, float dy, int shadowColor) 複製代碼
Paint.setMaskFilter(BlurMaskFilter(float radius, Blur style)) 複製代碼
第一種方式比價靈活,能夠設置的參數比較多,重點是陰影顏色是獨立的,無需和 Paint
畫筆的顏色同樣。因此採用第一種方式。
shadowPaint.setShadowLayer(shadowSpace/5f*4f, 0f, 0f, shadowColor)
複製代碼
這裏設置陰影的輻射範圍略小於預留的 shadowSpace
這樣陰影效果比較天然,不會出現明顯的邊界線。
在初始化的時候,設置了控件的 padding
,爲繪製陰影留下足夠的距離
setPadding((shadowSpace + paddingLeft).toInt(), (shadowSpace + paddingTop).toInt(), (shadowSpace + paddingRight).toInt(), (shadowSpace + paddingBottom).toInt()) 複製代碼
能夠看到,在控件的 padding
基礎上,加上了 shadowSpace
來控制 子View
的顯示範圍,以及陰影的顯示範圍。
最後來看看陰影繪製的範圍和圓角矩形裁剪範圍。
class RippleLayoutKtl: FrameLayout { // ...... override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) shadowRect.set(shadowSpace, shadowSpace, w - shadowSpace, h - shadowSpace) clipPath.addRoundRect(shadowRect, radius, radius , Path.Direction.CW) } // ...... 複製代碼
在監聽到控件尺寸變化的時候,設置 陰影 shadowRect
和 裁剪 clipPath
參數。而後在 dispatchDraw
中使用便可。
簡單說一下收縮 水波紋
的過程:
在水波紋 已經展開
,或者在 擴散的過程當中
,用戶再次點擊了控件,這時候,須要把水波紋 收縮回來
。
class RippleSelectFrameLayoutKtl: FrameLayout { //...... private fun shrinkRipple() { scroller.forceFinished(false) longestRadius = curRadius scroller.startScroll(curRadius.toInt(), 0, -curRadius.toInt(), 0, 800) drawing = true invalidate() } //...... } 複製代碼
首先調用 scroller.forceFinished(false)
把當前的動畫中止,而後以當前的水波紋半徑做爲最大半徑,設置給 scroller
,而且變化範圍是 -curRadius
,也就是說,半徑在動畫過程當中愈來愈小,直至爲 0
。
如此,水波紋就收縮回去了。
最後就是一些收尾處理了:
再也不細說,詳情請看 源碼(Java 版和 Kotlin都有哦,歡迎享有,香的話給個Star呀🧡)
做爲前端開發者,每每想要給用戶一個更好的使用體驗,無奈現實種種,可是不管如何,在有可能的狀況下,仍是要去尋求一些體驗和需求的平衡,至少在App的某些角落,用戶在用到某個功能的時候,會突然感受很舒服就足夠了。