手撕一个让人「欲罢不能」的水波纹选中控件
一、前言
在 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 以后,要实现水波纹的效果点击效果很简单,只需配置 ripple
的 drawable
就可以了。
但是系统自带的水波纹效果只是一个短暂的点击响应过程,也就是最后水波纹消失了。
如果要让水波纹扩散后保持住,比如实现一个水波纹选中效果,就无法实现了。
原生的水波纹效果就不说了,相信大家都会。下边就来看看如何通过自定View的方式实现一个水波纹选中的效果。
自定义水波纹选中控件的步骤
仔细看下这个点击选中的过程,可以拆分为以下几个过程:
- 获取点击的位置坐标
- 以点击位置为原点,不断绘制半径不断扩大的同心圆
- 提升控件
z轴
,其实就是绘制阴影 - 控件圆角裁剪
三、实现水波纹选中效果
需要哪些工具
开始之前,来看看整个定制过程需要用到哪些工具:
- 继承自FrameLayout 或 View
- Paint:画笔工具
- Scroller:实现水波纹扩散或者收缩动画
- Path 或者 RectF 用于设置裁剪的范围
- 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 动画
水波纹这里以控件中心为例,同心圆不断扩展,最后覆盖整个控件。我们知道,同心圆绘制的时候,超出控件的部分会被自动截断,所以最后效果是这样的
水波纹要想覆盖整个控件,则
触摸点在控件中间同心圆的最长半径,等于触摸点到控件
四个顶点
四个距离中最长的那个,而半径的大小只要利用勾股定理
就可以计算出来。
这里把触摸点分为在控件 左和右
两种情况,如下:
这样,利用 勾股定理
分别计算 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 中,实现裁剪有两种方式:
- clipXXX 方法:
clipRect
或clipPath
等,指定裁剪范围 - PorterDuffXfermode 颜色混合裁剪方法:通过设置不同的
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句代码,分别两两对应
- 【1.1】-【1.2】:新建一个绘制图层
什么作用呢?
系统画布上,默认只有一个图层,也就是说,所有的绘制都直接作用于这个图层上。这时如果你想要一个干净的图层来绘制一些东西,或者实现一些效果,就可以通过 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
方法中设置,后文会讲解。
- 【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)
}
// ......
}
绘制阴影和非常简单,两句代码就可以实现:
- 开启软件渲染模式。系统默认开始硬件渲染模式,如果不开启软件渲染的话,是无法绘制出阴影的。
-
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())
}
- 设置阴影
有两种方法:
- Paint.setShadowLayer
/**
* radius: 为阴影半径,就是上边绘制圆角矩形后,阴影超出矩形的距离
* dx/dy: 阴影的偏移距离
* shadowColor: 阴影的颜色。color为不透明时,透明度由shadowPaint的alpha决定,否则由shadowColor决定。
*/
public void setShadowLayer(float radius, float dx, float dy, int shadowColor)
- 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
。
如此,水波纹就收缩回去了。
五、收尾
最后就是一些收尾处理了:
- 加入xml可配置属性,如水波纹颜色,阴影大小,阴影颜色,圆角大小等
- 加入状态回调,把当前水波纹的状态传递出去
- ....
不再细说,详情请看 源码(Java 版和 Kotlin都有哦,欢迎享有,香的话给个Star呀🧡)
作为前端开发者,往往想要给用户一个更好的使用体验,无奈现实种种,但是无论如何,在有可能的情况下,还是要去寻求一些体验和需求的平衡,至少在App的某些角落,用户在用到某个功能的时候,会忽然感觉很舒服就足够了。