查看原文
其他

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

点击关注 👉 技术最TOP 2022-08-26

作者:彭也

链接: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(00, 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(00, srcBm?.width!!, srcBm?.height!!), Rect(00, 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---


推荐一个公众号,专注互联网大厂面试、面试题分享,大厂面试一网打尽:

西哥好友位开放,还没有加西哥好友的,可以扫下面二维码加个好友,有职场、技术相关问题,随时咨询


推荐阅读:
曝光中国女性的私密数据-广西富婆多,快上车!
序列化:ProtoBuf 与 JSON 的比较 !
微信重磅更新,电脑上也可刷朋友圈了!
App极限瘦身姿势: png 打包自动化转换 webp
Gradle 插件 + ASM 实战 - 监控图片加载告警
Android 12 全新软件截图曝光:采用 Material Next 设计标准
【译】Flutter 2 正式版的新功能,一睹为快
自定义view仿写今日头条点赞动画!


更文不易,点个“在看”支持一下👇

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存