閱讀完本文約需10分鐘
接上回,廢不說,看圖

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

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

效果還不錯(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)效果。

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

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

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)行逼真地模擬。

2.2 UI繪制
2.2.1 繪制path
定義關(guān)鍵點(diǎn)

代碼如下
/**
* 構(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 繪制指示器

可以看到,在控件收縮狀態(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è)置過抗鋸齒。放大可以看到

至此,大部分屏幕分辨率較高的實(shí)機(jī)上都可以較好的運(yùn)行了,看不出毛刺感。但阿也真的很嚴(yán)格,低分辨率的機(jī)器上毛刺感也是需要解決的。
2.2.4 解決毛刺感
采用非clipPath方案,使用圖形疊加效果的設(shè)置解決形狀邊緣的毛刺感。通過Paint.setXfermode進(jìn)行設(shè)置,參數(shù)通過PorterDuff.Mode枚舉進(jìn)行選取。

代碼如下:
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ì)比放大看下

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)畫為例
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)定收益。