在 Android 5.0
之后,随着 Material Design
的提出,Android UI 设计语言可谓是提高了一大步,可是在国内其实并无获得很大的推广应用。前端
一是,要设计一个彻底遵循 Material Design
的App,UI设计师须要花费比较多的时间,开发者开发一样须要花费更多的时间去实现,而国内的环境你们都知道的。git
二是,Material Design
有许多的过渡动画和酷炫的效果,没法避免的会有一些性能上的损耗。github
三是,国内对于App使用体验上,虽然有了很大的提高,可是依然不如国外重视。canvas
不过,即便不能大规模的应用 Material Design
,也不妨碍咱们在一些特别的地方去实现一些效果,毕竟梦想仍是要有的嘛。bash
本文水波纹控件源码:传送门(Java 版和 Kotlin都有哦,欢迎享用,香的话给个Star呀🧡)markdown
一般状况下,在实现一个 点击 -> 选中
的时候,最简单粗暴的方式就是点击以后,给控件直接更换一个 背景色/背景图
,可是这种效果每每是很是僵硬的,和用户没有很好的交互过程。ide
Material Design
就给出了很好的指导,好比点击的时候控件有一个 z轴
的提高,控件背景色根据手指点击的位置出现一个过渡的效果。函数
好比今天要介绍的这个水波纹选中效果。工具
有了这些以后,你会发现,整个点击选中的体验大幅提高,会让人有一个丝丝顺滑的感受,若是体验足够好,甚至会让人点上瘾,你会不自觉地在不一样的按钮来回点击,体验这种舒服的过渡感。oop
咱们知道在 Android 5.0 之后,要实现水波纹的效果点击效果很简单,只需配置 ripple
的 drawable
就能够了。可是系统自带的水波纹效果只是一个短暂的点击响应过程,也就是最后水波纹消失了。
若是要让水波纹扩散后保持住,好比实现一个水波纹选中效果,就没法实现了。
原生的水波纹效果就不说了,相信你们都会。下边就来看看如何经过自定View的方式实现一个水波纹选中的效果。
仔细看下这个点击选中的过程,能够拆分为如下几个过程:
z轴
,其实就是绘制阴影开始以前,来看看整个定制过程须要用到哪些工具:
以上,都是在自定义View中常常用到的工具。
这里选择 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在后文再详细讲解。
上面的代码中,重写了 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 中,实现裁剪有两种方式:
clipRect
或 clipPath
等,指定裁剪范围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句代码,分别两两对应
什么做用呢?
系统画布上,默认只有一个图层,也就是说,全部的绘制都直接做用于这个图层上。这时若是你想要一个干净的图层来绘制一些东西,或者实现一些效果,就能够经过 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
方法中设置,后文会讲解。
这两句就是对应了设置和取消 裁剪模式
。
先绘制底部 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()) } 复制代码
有两种方法:
/** * radius: 为阴影半径,就是上边绘制圆角矩形后,阴影超出矩形的距离 * dx/dy: 阴影的偏移距离 * shadowColor: 阴影的颜色。color为不透明时,透明度由shadowPaint的alpha决定,不然由shadowColor决定。 */ public void setShadowLayer(float radius, float dx, float dy, int shadowColor) 复制代码
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
。
如此,水波纹就收缩回去了。
最后就是一些收尾处理了:
再也不细说,详情请看 源码(Java 版和 Kotlin都有哦,欢迎享有,香的话给个Star呀🧡)
做为前端开发者,每每想要给用户一个更好的使用体验,无奈现实种种,可是不管如何,在有可能的状况下,仍是要去寻求一些体验和需求的平衡,至少在App的某些角落,用户在用到某个功能的时候,会突然感受很舒服就足够了。