14

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

 4 years ago
source link: https://juejin.im/post/5e2172cdf265da3e2c24755b
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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. 加入状态回调,把当前水波纹的状态传递出去

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

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

1




About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK