液体流动控件,隔壁产品都馋哭了~
作者:彭也
链接:https://www.jianshu.com/p/4f0844c72e8a
模拟液体流动的展开特效,适合一些需要侧边展开进行辅助说明的页面,如用户在填写某个表单,需要操作很多步骤,有这么一个侧边栏控件,用户可以随时展开查看操作指引。
适合app首次启动的宣传引导图
效果还不错,体验比较新奇。
市面上在应用中模拟液体流动的效果大部分都是一个正弦函数式的波浪循环滚动,没有交互灵魂,宛如一个没有感情的复读机。
为了使交互更新鲜,设计了这款具备展开、收缩状态的液体流动控件,收缩状态下,控件收缩在屏幕右侧;展开过程中,跟随用户手指的滑动模拟液体流动效果。
实现方案
2.0 类设计
顶点的移动本质上是坐标的移动,坐标的移动本质上是横坐标和纵坐标的移动,定义一个坐标类Coordinate
open class Coordinate {
constructor() {
}
var x: Float = 0F
var y: Float = 0F
var xFunc: IFunc? = null
var yFunc: IFunc? = null
override fun toString(): String {
return "Coordinate(x=$x, y=$y, xFunc=$xFunc, yFunc=$yFunc)"
}
}
横纵坐标的移动本质上是随一个或几个输入变量进行变化的函数,运用远古人传下来的设计模式中的策略模式思想进行设计,定义IFunc接口,坐标值通过execute方法计算得出,类关系如下:
IFunc类:
interface IFunc {
/**
* 初始值
*/
var initValue: Float
/**
* 入参的阈值
*/
var inParamMax: Float
/**
* 入参的阈值
*/
var inParamMin: Float
/**
* 出参的阈值
*/
var outParamMax:Float
/**
* 出参的阈值
*/
var outParamMin:Float
fun execute(inParam: Float): Float
}
2.1 UI拆解
2.1.1 形状分析
从形状上看,应该是由收缩状态下一个带有突起的波纹形状和展开状态下的全屏矩形构成,状态切换的过程就是由波纹形状变成矩形形状的过程,有点类似SVG动画
2.1.2 方案参考
从形状上看大致可以猜到应该和贝斯尔曲线有关,也可能是某个数学函数的函数图。这里采用贝塞尔曲线,可以更好的运用坐标值计算框架。找好贝塞尔曲线的关键坐标点,针对每个点进行做坐标值变换计算
2.2 UI绘制
2.2.1 绘制path
定义关键点
代码如下
/**
* 构成波浪的关键点坐标
*/
var pointA: Coordinate = Coordinate()
var pointB: Coordinate = Coordinate()
var pointC: Coordinate = Coordinate()
var pointD: Coordinate = Coordinate()
var pointE: Coordinate = Coordinate()
var pointF: Coordinate = Coordinate()
var pointG: Coordinate = Coordinate()
//当前路径
var path: Path = Path()
生成路径
代码如下
private fun configPath(): Path {
path.reset()
path.moveTo(width.toFloat(), 0F)
path.lineTo(pointA.x, 0F)
path.lineTo(pointA.x, pointA.y)
path.quadTo(pointB.x, pointB.y, pointC.x, pointC.y)
path.quadTo(pointD.x, pointD.y, pointE.x, pointE.y)
path.quadTo(pointF.x, pointF.y, pointG.x, pointG.y)
path.lineTo(pointG.x, pointG.y)
path.lineTo(pointG.x, height.toFloat())
path.lineTo(width.toFloat(), height.toFloat())
path.close()
return path
}
2.2.2 绘制指示器
可以看到,在控件收缩状态下,有一个向左的箭头指示器,这里采用bitmap
private fun drawIndicator(canvas: Canvas?) {
if (isNeedDrawBackBm == false) {
return
}
canvas?.apply {
if (backBm == null) {
backBm = BitmapFactory.decodeResource(resources, R.drawable.img_back)
backBm?.setHasAlpha(true)
}
val backBmCenterX: Int = (width - oriWaveHeight / 2).toInt()
val backBmCenterY: Int = height / 2
this.drawBitmap(backBm!!, Rect(0, 0, backBm!!.width, backBm!!.height), Rect(backBmCenterX - (oriWaveHeight / 8).toInt(), backBmCenterY - (oriWaveHeight / 8).toInt(), backBmCenterX + (oriWaveHeight / 8).toInt(), backBmCenterY + (oriWaveHeight / 8).toInt()), null)
}
}
2.2.3 ImageView方案
一开始我思考应该可以用继承ImageView的进行图片绘制,只需裁剪canvas即可,onDraw中一行代码搞定,还可以在xml布局中使用所有ImageView的属性配置
class FlowView : View {
fun onDraw(canvas:Canvas?){
canvas?.let{
it.clipPath(path)
}
super.onDraw(canvas)
}
}
但此时会带来个问题,此时的path并未和paint进行共同操作,对画布裁剪时可能会出现毛刺感, 无论你是否设置过抗锯齿。
至此,大部分屏幕分辨率较高的实机上都可以较好的运行了,看不出毛刺感。但低分辨率的机器上毛刺感也是需要解决的。
2.2.4 解决毛刺感
采用非clipPath方案,使用图形叠加效果的设置解决形状边缘的毛刺感。通过Paint.setXfermode进行设置,参数通过PorterDuff.Mode枚举进行选取。
代码如下:
private fun clipSrcBm() {
paint.xfermode = null
if (tempBm == null) {
tempBm = Bitmap.createBitmap(srcBm?.width!!, srcBm?.height!!, Bitmap.Config.ARGB_8888)
}
if (tempCanvas == null) {
tempCanvas = Canvas(tempBm!!)
}
tempCanvas?.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
tempCanvas?.drawPath(path, paint)
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
tempCanvas?.drawBitmap(srcBm!!, Rect(0, 0, srcBm?.width!!, srcBm?.height!!), Rect(0, 0, width, height), paint)
}
边缘的毛刺感瞬间就木有了,对比放大看下
2.2.6 解决卡顿
需要注意到的是,绘制bitmap是个需要考虑性能的操作,android上设计图片的操作都需要谨慎处理。对于一些低端机器,如果该控件用于app引导图场景,可能会卡顿掉帧,解决方案是采用继承自SurfaceView的方案
class FlowSurfaceView : SurfaceView, SurfaceHolder.Callback, Runnable {
override fun run() {
while (isDrawing) {
canvas = holder.lockCanvas()
canvas?.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
drawWave(canvas)
drawSrcBm(canvas)
drawIndicator(canvas)
canvas?.apply {
holder.unlockCanvasAndPost(this)
}
}
}
2.3 交互实现
2.3.1 配置关键点坐标变化公式
代码如下,以展开过程的坐标变换公式为例
fun configExpandFunc() {
pointA.xFunc = Func5(pointA.x, pointA.x)
val pointAyFunc = Func7(pointA.y, pointA.y)
pointAyFunc.rate = 3 * width / height.toFloat()
pointA.yFunc = pointAyFunc
pointB.xFunc = Func5(pointB.x, pointB.x)
val pointByFunc = Func7(pointB.y, pointB.y)
pointByFunc.rate = 2 * width / height.toFloat()
pointB.yFunc = pointByFunc
pointC.xFunc = Func5(pointC.x, pointC.x)
val pointCyFunc = Func7(pointC.y, pointC.y)
pointCyFunc.rate = width / height.toFloat()
pointC.yFunc = pointCyFunc
pointE.xFunc = Func5(pointE.x, pointE.x)
val pointEyFunc = Func8(pointE.y, height.toFloat())
pointEyFunc.rate = width / height.toFloat()
pointEyFunc.inParamMin = pointE.y
pointE.yFunc = pointEyFunc
pointF.xFunc = Func5(pointF.x, pointF.x)
val pointFyFunc = Func8(pointF.y, height.toFloat())
pointFyFunc.rate = 2 * width / height.toFloat()
pointFyFunc.inParamMin = pointF.y
pointF.yFunc = pointFyFunc
pointG.xFunc = Func5(pointG.x, pointG.x)
val pointGyFunc = Func8(pointG.y, height.toFloat())
pointGyFunc.rate = 3 * width / height.toFloat()
pointGyFunc.inParamMin = pointG.y
pointG.yFunc = pointGyFunc
}
2.3.2 跟随用户手指移动而变化
代码如下,其中offset为用户手指滑动的X轴方向的距离
private fun executePointFunc(point: Coordinate, offset: Float) {
point.xFunc?.let {
point.x = it.execute(offset)
}
point.yFunc?.let {
point.y = it.execute(offset)
}
}
2.3.3 动画实现
代码如下,以收缩动画为例
fun startShrinkAnim() {
offsetAnimator?.cancel()
offsetAnimator = ValueAnimator.ofFloat(offsetX, width.toFloat())
offsetAnimator?.let {
it.duration = DURATION_ANIMATION
it.interpolator = AccelerateDecelerateInterpolator()
it.addUpdateListener {
val tempOffsetX: Float = it.animatedValue as Float
executePointFunc(pointA, tempOffsetX)
executePointFunc(pointB, tempOffsetX)
executePointFunc(pointC, tempOffsetX)
getPointDCoordinate(pointB, pointC)
executePointFunc(pointE, tempOffsetX)
executePointFunc(pointF, tempOffsetX)
executePointFunc(pointG, tempOffsetX)
postInvalidate()
}
it.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation)
isNeedDrawBackBm = true
//重新设置变换函数
configExpandFunc()
resetInitValueFunc(pointA)
resetInitValueFunc(pointB)
resetInitValueFunc(pointC)
getPointDCoordinate(pointB, pointC)
resetInitValueFunc(pointE)
resetInitValueFunc(pointF)
resetInitValueFunc(pointG)
}
})
it.start()
}
isExpanded = false
listener?.onStateChanged(STATE_SHRINKED)
}
2.3.4 事件传递处理
需要注意的是,当控件处于收缩状态,用户点击空白区域,应该将事件继续传递下去,封装一个判断用户点击坐标是否在path内部的方法
private fun isInWavePathRegion(x: Float, y: Float): Boolean {
val rectF = RectF()
path.computeBounds(rectF, true)
val region = Region()
region.setPath(path, Region(rectF.left.toInt(), rectF.top.toInt(), rectF.right.toInt(), rectF.bottom.toInt()))
if (region.contains(x.toInt(), y.toInt())) {
return true
}
return false
}
如果不在path内部,交给父类处理
if (isInWavePathRegion(downX, downY)) {
isEffectOperation = true
postInvalidate()
} else {
return super.onTouchEvent(event)
}
后记
模拟液体流动效果有很多方案,可以像本文一样使用贝塞尔曲线,也可以使用指定的函数绘制曲线,无论哪种方案,本质上都是数学问题。
只可惜当年我的体育老师不给力,大部分数学知识都没塞进脑子里。使用本文中的坐标值计算框架的好处是不用研究复杂的数学函数,将数学函数图像的变化转换成每个坐标点的坐标变化。
这种由大化小的分化思想在现实中有很多应用。
源码学习地址:
https://gitee.com/null_077_5468/uidemos
---END---
推荐一个公众号,专注互联网大厂面试、面试题分享,大厂面试一网打尽:
更文不易,点个“在看”支持一下👇