液体流动控件,隔壁产品都馋哭了

1,104 阅读6分钟

读完本文约需10分钟

接上回,废不说,看图

模拟液体流动的展开特效,适合一些需要侧边展开进行辅助说明的页面,如用户在填写某个表单,需要操作很多步骤,有这么一个侧边栏控件,用户可以随时展开查看操作指引。

也适合app首次启动的宣传引导图

![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b7681192600f44cf80565d5b09952f03~tplv-k3u1fbpfcp-zoom-1.image)

效果还不错,体验比较新奇

一、设计思路

市面上在应用中模拟液体流动的效果大部分都是一个正弦函数式的波浪循环滚动,没有交互灵魂,宛如一个没有感情的复读机。为了使交互更新鲜,设计了这款具备展开、收缩状态的液体流动控件,收缩状态下,控件收缩在屏幕右侧;展开过程中,跟随用户手指的滑动模拟液体流动效果。

![](https://picb.zhimg.com/v2-55cfc8fac32bee74b93764e45e6c2852_b.jpg)

二、实现方案

方案的基础为往期文章《今日头条loading控件,隔壁产品都馋哭了》中提到的坐标值计算框架,类设计如下,这里不再详细说明

![](https://picb.zhimg.com/80/v2-a84c71ef03cb4838dff4215742cf70bb_720w.jpg)

2.1 UI拆解

2.1.1 形状分析

从形状上看,应该是由收缩状态下一个带有突起的波纹形状和展开状态下的全屏矩形构成,状态切换的过程就是由波纹形状变成矩形形状的过程,有点类似SVG动画

![](https://pic1.zhimg.com/v2-47c7bc6f5c22f4e23ad039ad9b6e8ffd_b.jpg)

2.1.2 方案参考

从形状上看大致可以猜到应该和贝斯尔曲线有关,也可能是某个数学函数的函数图。这里采用贝塞尔曲线,可以更好得运用坐标值计算框架。找好贝塞尔曲线的关键坐标点,针对每个点进行做坐标值变换计算

2.2 UI绘制

2.2.1 绘制path

定义关键点

![](https://pic2.zhimg.com/80/v2-0ded65b0011b33e6c2fda1b608d51203_720w.jpg)

代码如下

/**
* 构成波浪的关键点坐标
*/
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()

生成路径

![](https://pic3.zhimg.com/80/v2-e7a15b24fddf363a1b2cc8f6a9ed2cdc_720w.jpg)

代码如下

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 绘制指示器

![](https://pic2.zhimg.com/80/v2-5cda78365f86fab43fd072469efcc93f_720w.jpg)

可以看到,在控件收缩状态下,有一个向左的箭头指示器,这里采用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进行共同操作,对画布裁剪时可能会出现毛刺感, 无论你是否设置过抗锯齿。

![](https://pic1.zhimg.com/80/v2-7ad68fc1c40f43a6d0ae614f68556dd3_720w.jpg)

至此,大部分屏幕分辨率较高的实机上都可以较好的运行了,看不出毛刺感。但阿也真的很严格,低分辨率的机器上毛刺感也是需要解决的。

2.2.4 解决毛刺感

采用非clipPath方案,使用图形叠加效果的设置解决形状边缘的毛刺感。通过Paint.setXfermode进行设置,参数通过PorterDuff.Mode枚举进行选取。

![](https://pic3.zhimg.com/80/v2-234ca011982fee9b4630af3c371edc58_720w.jpg)

代码如下:

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)
}

边缘的毛刺感瞬间就木有了,对比放大看下

![](https://pic1.zhimg.com/80/v2-89f567684f762299cfae84e36645778d_720w.jpg)

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)
}

三、后记

模拟液体流动效果有很多方案,可以像本文一样使用贝塞尔曲线,也可以使用指定的函数绘制曲线,无论哪种方案,本质上都是数学问题。只可惜当年我的体育老师不给力,大部分数学知识都没塞进脑子里。使用本文中的坐标值计算框架的好处是不用研究复杂的数学函数,将数学函数图像的变化转换成每个坐标点的坐标变化。

这种由大化小的分化思想在现实中有很多应用。三人行,一人是强者,另外两人是弱者,强者可以碾压其中的任何一个弱者,而两个弱者联手的话可以轻松干掉弱者,此时强者为了争夺和稳固领导地位可以怎么做?强者只需要趁机解决掉其中一个弱者之后并给他一定的好处,局面就变成了强者控制一个可控的弱者和一个打不过自己的弱者,将两个弱者可以联合的大风险转换成分别对付两个弱者的稳定收益。

往期精彩:

做个锤子的Switch控件,隔壁产品都馋哭了

微质感的层级选择器,隔壁产品都馋哭了 妖媚的刻度尺控件,隔壁产品都馋哭了  

妖艳的SwitchButton控件-隔壁产品都馋哭了

 炫酷的Android时钟UI控件,隔壁产品都馋哭了