手撕一个让人「欲罢不能」的水波纹选中控件

1,348 阅读14分钟

一、前言

Android 5.0 以后,随着 Material Design 的提出,Android UI 设计语言可谓是提升了一大步,但是在国内其实并没有得到很大的推广应用。

一是,要设计一个完全遵循 Material Design 的App,UI设计师需要花费比较多的时间,开发者开发同样需要花费更多的时间去实现,而国内的环境大家都知道的。

二是,Material Design 有许多的过渡动画和酷炫的效果,无法避免的会有一些性能上的损耗。

三是,国内对于App使用体验上,虽然有了很大的提升,但是依然不如国外重视。

不过,即使不能大规模的应用 Material Design ,也不妨碍我们在一些特别的地方去实现一些效果,毕竟梦想还是要有的嘛。

本文水波纹控件源码:传送门(Java 版和 Kotlin都有哦,欢迎享用,香的话给个Star呀🧡)

二、水波纹控件的组成

通常情况下,在实现一个 点击 -> 选中 的时候,最简单粗暴的方式就是点击之后,给控件直接更换一个 背景色/背景图 ,但是这种效果往往是非常僵硬的,和用户没有很好的交互过程。

普通选中

Material Design 就给出了很好的指导,比如点击的时候控件有一个 z轴 的提升,控件背景色根据手指点击的位置出现一个过渡的效果。

比如今天要介绍的这个水波纹选中效果。

水波纹控件

有了这些之后,你会发现,整个点击选中的体验大幅提升,会让人有一个丝丝顺滑的感觉,如果体验足够好,甚至会让人点上瘾,你会不自觉地在不同的按钮来回点击,体验这种舒服的过渡感。

原生的水波纹

我们知道在 Android 5.0 以后,要实现水波纹的效果点击效果很简单,只需配置 rippledrawable 就可以了。但是系统自带的水波纹效果只是一个短暂的点击响应过程,也就是最后水波纹消失了。

如果要让水波纹扩散后保持住,比如实现一个水波纹选中效果,就无法实现了。

原生的水波纹效果就不说了,相信大家都会。下边就来看看如何通过自定View的方式实现一个水波纹选中的效果。

自定义水波纹选中控件的步骤

仔细看下这个点击选中的过程,可以拆分为以下几个过程:

  1. 获取点击的位置坐标
  2. 以点击位置为原点,不断绘制半径不断扩大的同心圆
  3. 提升控件 z轴,其实就是绘制阴影
  4. 控件圆角裁剪

三、实现水波纹选中效果

需要哪些工具

开始之前,来看看整个定制过程需要用到哪些工具:

  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 之前,把背景色和水波纹绘制上去就完成了。

四、圆角和阴影

如果实现水波纹的话,只要上面的代码就可以了。但是,这样效果还是不够细腻,我们要给控件实现 圆角裁剪阴影效果

圆角裁剪

在 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

如此,水波纹就收缩回去了。

五、收尾

最后就是一些收尾处理了:

  1. 加入xml可配置属性,如水波纹颜色,阴影大小,阴影颜色,圆角大小等
  2. 加入状态回调,把当前水波纹的状态传递出去
  3. ....

不再细说,详情请看 源码(Java 版和 Kotlin都有哦,欢迎享有,香的话给个Star呀🧡)

作为前端开发者,往往想要给用户一个更好的使用体验,无奈现实种种,但是无论如何,在有可能的情况下,还是要去寻求一些体验和需求的平衡,至少在App的某些角落,用户在用到某个功能的时候,会忽然感觉很舒服就足够了。