液體流動(dòng)控件,隔壁產(chǎn)品都饞哭了

閱讀完本文約需10分鐘

接上回,廢不說,看圖

flow (2).gif

模擬液體流動(dòng)的展開特效,適合一些需要側(cè)邊展開進(jìn)行輔助說明的頁面,如用戶在填寫某個(gè)表單,需要操作很多步驟,有這么一個(gè)側(cè)邊欄控件,用戶可以隨時(shí)展開查看操作指引。

image

也適合app首次啟動(dòng)的宣傳引導(dǎo)圖

image

效果還不錯(cuò),體驗(yàn)比較新奇

一、設(shè)計(jì)思路

市面上在應(yīng)用中模擬液體流動(dòng)的效果大部分都是一個(gè)正弦函數(shù)式的波浪循環(huán)滾動(dòng),沒有交互靈魂,宛如一個(gè)沒有感情的復(fù)讀機(jī)。為了使交互更新鮮,設(shè)計(jì)了這款具備展開、收縮狀態(tài)的液體流動(dòng)控件,收縮狀態(tài)下,控件收縮在屏幕右側(cè);展開過程中,跟隨用戶手指的滑動(dòng)模擬液體流動(dòng)效果。

image

二、實(shí)現(xiàn)方案

方案的基礎(chǔ)為往期文章《今日頭條loading控件,隔壁產(chǎn)品都饞哭了》中提到的坐標(biāo)值計(jì)算框架,類設(shè)計(jì)如下,這里不再詳細(xì)說明

image

2.1 UI拆解

2.1.1 形狀分析

從形狀上看,應(yīng)該是由收縮狀態(tài)時(shí)的一個(gè)帶有突起的波紋形狀和展開狀態(tài)下的全屏矩形構(gòu)成,狀態(tài)切換的過程就是由波紋形狀變成矩形形狀的過程,有點(diǎn)類似SVG動(dòng)畫

image

2.1.2 方案參考

從形狀上看大致可以猜到應(yīng)該和貝斯?fàn)柷€有關(guān),也可能是某個(gè)數(shù)學(xué)函數(shù)的函數(shù)圖。這里采用貝塞爾曲線,可以更好得運(yùn)用坐標(biāo)值計(jì)算框架。找好貝塞爾曲線的關(guān)鍵坐標(biāo)點(diǎn),針對(duì)每個(gè)點(diǎn)進(jìn)行做坐標(biāo)值變換計(jì)算。當(dāng)然,貝塞爾曲線非常強(qiáng)大,能繪制的內(nèi)容十分豐富,以下函數(shù)圖像都可以用貝塞爾曲線進(jìn)行逼真地模擬。

image

2.2 UI繪制

2.2.1 繪制path

定義關(guān)鍵點(diǎn)

image

代碼如下

/**
* 構(gòu)成波浪的關(guān)鍵點(diǎn)坐標(biāo)
*/
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()
//當(dāng)前路徑
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

可以看到,在控件收縮狀態(tài)下,有一個(gè)向左的箭頭指示器,這里采用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方案

一開始我思考應(yīng)該可以用繼承ImageView的進(jìn)行圖片繪制,只需裁剪canvas即可,onDraw中一行代碼搞定,還可以在xml布局中使用所有ImageView的屬性配置

class FlowView : View {

    fun onDraw(canvas:Canvas?){
        canvas?.let{
            it.clipPath(path)
        }
        super.onDraw(canvas)
    }
}

但此時(shí)會(huì)帶來個(gè)問題,此時(shí)的path并未和paint進(jìn)行共同操作,對(duì)畫布裁剪時(shí)可能會(huì)出現(xiàn)毛刺感,無論你是否設(shè)置過抗鋸齒。放大可以看到


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

至此,大部分屏幕分辨率較高的實(shí)機(jī)上都可以較好的運(yùn)行了,看不出毛刺感。但阿也真的很嚴(yán)格,低分辨率的機(jī)器上毛刺感也是需要解決的。

2.2.4 解決毛刺感

采用非clipPath方案,使用圖形疊加效果的設(shè)置解決形狀邊緣的毛刺感。通過Paint.setXfermode進(jìn)行設(shè)置,參數(shù)通過PorterDuff.Mode枚舉進(jìn)行選取。


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

邊緣的毛刺感瞬間就木有了,對(duì)比放大看下

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

2.2.6 解決卡頓

需要注意到的是,繪制bitmap是個(gè)需要考慮性能的操作,android上涉及圖片的操作都需要謹(jǐn)慎處理。對(duì)于一些低端機(jī)器,如果該控件用于app引導(dǎo)圖場景,可能會(huì)卡頓掉幀,解決方案是采用繼承自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 交互實(shí)現(xiàn)

2.3.1 配置關(guān)鍵點(diǎn)坐標(biāo)變化公式

代碼如下,以展開過程的坐標(biāo)變換公式為例

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 跟隨用戶手指移動(dòng)而變化

代碼如下,其中offset為用戶手指滑動(dòng)的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 動(dòng)畫實(shí)現(xiàn)

線框動(dòng)畫.gif

代碼如下,以收縮動(dòng)畫為例

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
                //重新設(shè)置變換函數(shù)
                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 事件傳遞處理

需要注意的是,當(dāng)控件處于收縮狀態(tài),用戶點(diǎn)擊空白區(qū)域,應(yīng)該將事件繼續(xù)傳遞下去,封裝一個(gè)判斷用戶點(diǎn)擊坐標(biāo)是否在path內(nèi)部的方法

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內(nèi)部,交給父類處理

if (isInWavePathRegion(downX, downY)) {
    isEffectOperation = true
    postInvalidate()
} else {
    return super.onTouchEvent(event)
}

三、后記

模擬液體流動(dòng)效果有很多方案,可以像本文一樣使用貝塞爾曲線,也可以使用指定的函數(shù)繪制曲線,無論哪種方案,本質(zhì)上都是數(shù)學(xué)問題。只可惜當(dāng)年我的體育老師不給力,大部分?jǐn)?shù)學(xué)知識(shí)都沒塞進(jìn)腦子里。使用本文中的坐標(biāo)值計(jì)算框架的好處是不用研究復(fù)雜的數(shù)學(xué)函數(shù),將數(shù)學(xué)函數(shù)圖像的變化轉(zhuǎn)換成每個(gè)坐標(biāo)點(diǎn)的坐標(biāo)變化。

這種由大化小的分化思想在現(xiàn)實(shí)中有很多應(yīng)用。三人行,一人是強(qiáng)者,另外兩人是弱者,強(qiáng)者可以碾壓其中的任何一個(gè)弱者,而兩個(gè)弱者聯(lián)手的話可以輕松干掉強(qiáng)者,此時(shí)強(qiáng)者為了爭奪和穩(wěn)固領(lǐng)導(dǎo)地位可以怎么做?強(qiáng)者只需要趁機(jī)解決掉其中一個(gè)弱者之后并給他一定的好處,局面就變成了強(qiáng)者控制一個(gè)可控的弱者和一個(gè)打不過自己的弱者,將兩個(gè)弱者可以聯(lián)合的大風(fēng)險(xiǎn)轉(zhuǎn)換成分別對(duì)付兩個(gè)弱者的穩(wěn)定收益。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容