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

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的某些角落,用户在用到某个功能的时候,会突然感受很舒服就足够了。

相关文章
相关标签/搜索