手撕一個讓人「欲罷不能」的水波紋選中控件

1、前言

Android 5.0 之後,隨着 Material Design 的提出,Android UI 設計語言可謂是提高了一大步,可是在國內其實並無獲得很大的推廣應用。前端

一是,要設計一個徹底遵循 Material Design 的App,UI設計師須要花費比較多的時間,開發者開發一樣須要花費更多的時間去實現,而國內的環境你們都知道的。git

二是,Material Design 有許多的過渡動畫和酷炫的效果,沒法避免的會有一些性能上的損耗。github

三是,國內對於App使用體驗上,雖然有了很大的提高,可是依然不如國外重視。canvas

不過,即便不能大規模的應用 Material Design ,也不妨礙咱們在一些特別的地方去實現一些效果,畢竟夢想仍是要有的嘛。bash

本文水波紋控件源碼:傳送門(Java 版和 Kotlin都有哦,歡迎享用,香的話給個Star呀🧡)markdown

2、水波紋控件的組成

一般狀況下,在實現一個 點擊 -> 選中 的時候,最簡單粗暴的方式就是點擊以後,給控件直接更換一個 背景色/背景圖 ,可是這種效果每每是很是僵硬的,和用戶沒有很好的交互過程。ide

普通選中

Material Design 就給出了很好的指導,好比點擊的時候控件有一個 z軸 的提高,控件背景色根據手指點擊的位置出現一個過渡的效果。函數

好比今天要介紹的這個水波紋選中效果。工具

水波紋控件

有了這些以後,你會發現,整個點擊選中的體驗大幅提高,會讓人有一個絲絲順滑的感受,若是體驗足夠好,甚至會讓人點上癮,你會不自覺地在不一樣的按鈕來回點擊,體驗這種舒服的過渡感。oop

原生的水波紋

咱們知道在 Android 5.0 之後,要實現水波紋的效果點擊效果很簡單,只需配置 rippledrawable 就能夠了。可是系統自帶的水波紋效果只是一個短暫的點擊響應過程,也就是最後水波紋消失了。

若是要讓水波紋擴散後保持住,好比實現一個水波紋選中效果,就沒法實現了。

原生的水波紋效果就不說了,相信你們都會。下邊就來看看如何經過自定View的方式實現一個水波紋選中的效果。

自定義水波紋選中控件的步驟

仔細看下這個點擊選中的過程,能夠拆分爲如下幾個過程:

  1. 獲取點擊的位置座標
  2. 以點擊位置爲原點,不斷繪製半徑不斷擴大的同心圓
  3. 提高控件 z軸,其實就是繪製陰影
  4. 控件圓角裁剪

3、實現水波紋選中效果

須要哪些工具

開始以前,來看看整個定製過程須要用到哪些工具:

  1. 繼承自FrameLayout 或 View
  2. Paint:畫筆工具
  3. Scroller:實現水波紋擴散或者收縮動畫
  4. Path 或者 RectF 用於設置裁剪的範圍
  5. PorterDuffXfermode:顏色混合裁剪工具

以上,都是在自定義View中常常用到的工具。

繼承自 FrameLayout

這裏選擇 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在後文再詳細講解

獲取點擊,計算水波紋最長半徑

  • 記錄水波紋圓心座標 center

上面的代碼中,重寫了 onTouchEvent ,並在接收到按下事件時,開始擴展水波或者收縮水波紋,而且記錄下手指按下的位置,這個位置就是水波紋的圓心,記錄爲 center.x center.y

  • 計算水波紋最長半徑

看一個簡單的 gif 動畫

水波紋

這裏以控件中心爲例,同心圓不斷擴展,最後覆蓋整個控件。咱們知道,同心圓繪製的時候,超出控件的部分會被自動截斷,因此最後效果是這樣的

水波紋

要想覆蓋整個控件,則

同心圓的最長半徑,等於觸摸點到控件 四個頂點 四個距離中最長的那個,而半徑的大小隻要利用 勾股定理 就能夠計算出來。

觸摸點在控件中間

這裏把觸摸點分爲在控件 左和右 兩種狀況,以下:

觸摸點在控件左邊

觸摸點在控件右邊

這樣,利用 勾股定理 分別計算 R1R2 ,而後取其中比較大的那個,就是咱們想要的最長半徑了。

具體計算請看以上 getLongestRadius 方法。

觸發水波紋繪製動畫

首先看下觸發水波紋擴散的方法:

class RippleLayoutKtl: FrameLayout {

    // ......
    
    private fun expandRipple() {
        drawing = true
        longestRadius = getLongestRadius()
        scroller.startScroll(0, 0, ceil(longestRadius).toInt(), 0, 1200)
        invalidate()
    }
    
    // ......
    
}
複製代碼

在這個方法中,經過 getLongestRadius 使用上面介紹的計算方法,獲得了最長半徑, 並保存下來。

而後經過 Scrolle#startScroll 方法開啓一輪動畫。

關於動畫,實現的方法有不少,好比 ValueAnimatorHandler定時、甚至可使用線程的方式,可是在 自定義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 相關的參數),這樣就能夠獲得從 0longestRadius 遞增的同心圓半徑。

  • 實現動畫

經過 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 以前,把背景色和水波紋繪製上去就完成了。

4、圓角和陰影

若是實現水波紋的話,只要上面的代碼就能夠了。可是,這樣效果仍是不夠細膩,咱們要給控件實現 圓角裁剪陰影效果

圓角裁剪

在 Android 自定 View 中,實現裁剪有兩種方式:

  1. clipXXX 方法:clipRectclipPath 等,指定裁剪範圍
  2. PorterDuffXfermode 顏色混合裁剪方法:經過設置不一樣的 PorterDuff 混合模式能夠實現豐富的裁剪樣式。

然而,經過 clipXXX 方式裁剪時,若是有圓角的狀況下會出現邊緣鋸齒,因此這裏 採用第二種方式

首先來看看 PorterDuffXfermode 顏色混合模式有哪些:

顏色混合模式

能夠看到,經過不一樣的模式,能夠控制下層 DST 和上層 SRC 兩層圖層造成不同的渲染效果。

本文采用的是 SRC_ATOP,即在 SRCDST交匯的地方顯示上層的顏色,其餘位置通通不繪製。

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句代碼,分別兩兩對應

  • 【1.1】-【1.2】:新建一個繪製圖層

什麼做用呢?

系統畫布上,默認只有一個圖層,也就是說,全部的繪製都直接做用於這個圖層上。這時若是你想要一個乾淨的圖層來繪製一些東西,或者實現一些效果,就能夠經過 canvas.saveLayer 方法來新建一個 全透明 的圖層,而後在這個新圖層上渲染,最後經過 canvas.restoreToCount 將渲染好畫面,繪製到系統提供的默認圖層上。

這裏爲何要使用這個方法呢?

按照 PorterDuffXfermode 混合模式,應該是不須要新建一個圖層就能夠實現顏色混剪的。實驗發現,若是使用系統默認的圖層,沒法實現正常的裁剪。

這篇文章做者也遇到了相同的問題,通過的他實驗發現:

PorterDuffXfermode 顏色混合中的 SRC 層是在設置xfermode 以前 整個canvas 中的 非透明像素點

也就是說,默認的圖層整個 canvas 都有顏色了,和 DST 混合以後,若是混合模式爲 SRC_ATOP 的話呈現的依然是整個 DST ,沒法實現裁剪效果。

也有人說是由於 SRCDST都要爲 Bitmap,好比這篇文章

本文驗證了第一種,發現是一致的,第二種就沒有嘗試了,有興趣的能夠去試驗一下。

因而這裏新建了一個新的 全透明的 圖層,因爲 canvas.drawPath(clipPath, normalPaint) 繪製的是一個帶有圓角的矩形,設置了 xfermode 模式爲 SRC_ATOP ,繪製的時候,水波紋同心圓圓角矩形 交匯的地方就會顯示 水波紋的顏色,其他透明的地方不顯示。

注:clipPath 在 onSizeChanged 方法中設置,後文會講解。

  • 【2.1】-【2.2】:設置顏色混合模式

這兩句就是對應了設置和取消 裁剪模式

先繪製底部 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)
    }
    
    // ......
}
複製代碼

繪製陰影和很是簡單,兩句代碼就能夠實現:

  1. 開啓軟件渲染模式。系統默認開始硬件渲染模式,若是不開啓軟件渲染的話,是沒法繪製出陰影的。
  2. 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())
}

複製代碼
  • 設置陰影

有兩種方法:

  1. Paint.setShadowLayer
/**
 * radius: 爲陰影半徑,就是上邊繪製圓角矩形後,陰影超出矩形的距離
 * dx/dy: 陰影的偏移距離
 * shadowColor: 陰影的顏色。color爲不透明時,透明度由shadowPaint的alpha決定,不然由shadowColor決定。
 */
public void setShadowLayer(float radius, float dx, float dy, int shadowColor) 

複製代碼
  1. Paint.setMaskFilter
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

如此,水波紋就收縮回去了。

5、收尾

最後就是一些收尾處理了:

  1. 加入xml可配置屬性,如水波紋顏色,陰影大小,陰影顏色,圓角大小等
  2. 加入狀態回調,把當前水波紋的狀態傳遞出去
  3. ....

再也不細說,詳情請看 源碼(Java 版和 Kotlin都有哦,歡迎享有,香的話給個Star呀🧡)

做爲前端開發者,每每想要給用戶一個更好的使用體驗,無奈現實種種,可是不管如何,在有可能的狀況下,仍是要去尋求一些體驗和需求的平衡,至少在App的某些角落,用戶在用到某個功能的時候,會突然感受很舒服就足夠了。

相關文章
相關標籤/搜索