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

阅读完本文约需10分钟

接上回,废不说,看图

flow (2).gif

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

image

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

image

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

一、设计思路

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

image

二、实现方案

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

image

2.1 UI拆解

2.1.1 形状分析

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

image

2.1.2 方案参考

从形状上看大致可以猜到应该和贝斯尔曲线有关,也可能是某个数学函数的函数图。这里采用贝塞尔曲线,可以更好得运用坐标值计算框架。找好贝塞尔曲线的关键坐标点,针对每个点进行做坐标值变换计算。当然,贝塞尔曲线非常强大,能绘制的内容十分丰富,以下函数图像都可以用贝塞尔曲线进行逼真地模拟。

image

2.2 UI绘制

2.2.1 绘制path

定义关键点

image

代码如下

/**
* 构成波浪的关键点坐标
*/
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 绘制指示器

屏幕截图 2020-09-12 174938.png

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


屏幕截图 2020-09-12 172527.png

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

2.2.4 解决毛刺感

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


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

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

屏幕截图 2020-09-12 175728.png

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 动画实现

线框动画.gif

代码如下,以收缩动画为例

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

三、后记

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,837评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,551评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,417评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,448评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,524评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,554评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,569评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,316评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,766评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,077评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,240评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,912评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,560评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,176评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,425评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,114评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,114评论 2 352