前言
上一篇文章:仿微信滑動按鈕
本文是自定義View實踐第二篇,上一篇實現了一個簡單的滑動按鈕,知道了一些自定義View的基本步驟,本文是使用貝塞爾曲線實現的一個加載中控件,接下來進入正文講解。
效果圖
可以看到,WaveLoadingView除了用于loading外,還可以用于顯示進度的場景。
實現方式
在效果圖中,波浪是曲線的形式的,所以我們需要想辦法把曲線畫出來,在數學領域中,用于實現曲線的函數有很多種,但在開發中,比較常用的就是正弦曲線和貝塞爾曲線了,下面簡單介紹一下:
1、正弦曲線
正弦曲線是我們非常熟悉的曲線,它的函數如下:
y = Asin(ωx + φ) + h
A表示振幅,用于表示曲線的波峰和波谷的距離;
ω表示角速度,用于控制正弦曲線的周期;
φ表示初相,用于控制正弦曲線的左右移動;
h表示編劇,用于控制曲線的上下移動.
當A、ω、h取一定的值,φ取不同的值時,就可以讓曲線在水平方向移動起來,如下:
上面是A = 2,ω = 0.8, h = 0, φ不斷變化的正弦曲線。
2、貝塞爾曲線
貝塞爾曲線有一階、二階、... 、n階,一階的貝塞爾曲線是一條直線,從第2階開始才是曲線,n階的貝塞爾曲線可以由(n - 1)階貝塞爾曲線推導出來,關于貝塞爾曲線的推導可以閱讀深入理解貝塞爾曲線。
這里我使用二階貝塞爾曲線,它的函數如下:
f(t) = (1- t)^2 * P0 + 2t(1- t)P1 + t^2 * P2 (0<= t <= 1)
P0、P1、P2都是已知的點,稱為控制點, t是一個變量,范圍為0到1,函數值隨著t的變化而變化.
下面我們取P0 = (x0, y0) = (-20, 0),P1 = (x1, y1) = (-10, 20),P2 = (x2, y2) = (0, 0),然后把這3個點的值代入二階貝塞爾曲線函數,形成的曲線如下:
這樣就畫出了一條曲線(那兩條直線是用于輔助的),接下來我們繼續取P3 = (x3, y3) = (10, -20),P4 = (x4, y4) = (20, 0),然后把P2、P3、P4再次代入二階貝塞爾曲線函數,形成的曲線如下:
這樣就有點接近正弦曲線了,只要我們不斷的取控制點,不斷的代入二階貝塞爾曲線函數,就可以形成一條周期的曲線,到這里我們也發現了二階貝塞爾曲線函數不是一個周期函數,所以它不像正向曲線那樣連綿不絕,一個二階貝塞爾曲線函數一次只能通過3個控制點畫出一條曲線。
3、如何選擇?
我們也發現了貝塞爾曲線相對正弦曲線的實現有點復雜,但是,在Android中,貝塞爾曲線已經有了封裝好的api供我們使用,使用起來非常簡單,不需要我們去用代碼實現那個函數,相反正弦曲線就需要我們從零做起,要用代碼去實現正弦函數,還要進行大量計算、范圍檢查等,所以從使用的復雜來看,選用貝塞爾曲線的工作量更小一點。
在Android中,貝塞爾曲線是通過Path來實現的,在Path中,與二階貝塞爾曲線有關的函數是:
path.quadTo(x1, y1, x2, y2)//絕對坐標
path.rQuadTo(x1, y1, x2, y2)//相對坐標
再貼一次圖一的貝塞爾曲線:
假設坐標系參考圖一的xy軸,即x軸向右,y軸向上,原點是(0, 0), 通過以下代碼就可以畫出上圖的曲線,如下:
var path = Path()
path.moveTo(-20f, 0f)//(x0, y0) = (-20, 0)
path.quadTo(
-10f, 20f, //(x1, y1) = (-10, 20)
0f, 0f //(x2, y2) = (0, 0)
)
//上面是絕對坐標,下面代碼使用相對坐標的方式畫出
var path = Path()
path.moveTo(-20f, 0f)//(x0, y0) = (-20, 0)
path.rQuadTo(
10f, 20f, //(x1, y1)相對(x0, y0)為(10, 20)
20f, 0f //(x2, y2)相對(x0, y0)為(20, 0)
)
如果想要畫出圖二的貝塞爾曲線,只需要在前面曲線的基礎上再加一句quadTo或rQuadTo,如下:
var path = Path()
path.moveTo(-20f, 0f)//(x0, y0) = (-20, 0)
path.quadTo(
-10f, 20f, //(x1, y1) = (-10, 20)
0f, 0f //(x2, y2) = (0, 0)
)
path.quadTo(
10f, -20f, //(x3, y3) = (10, -20)
20f, 0f //(x4, y4) = (20, 0)
)
//上面是絕對坐標,下面代碼使用相對坐標的方式畫出
var path = Path()
path.moveTo(-20f, 0f)//(x0, y0) = (-20, 0)
path.rQuadTo(
10f, 20f, //(x1, y1)相對(x0, y0)為(10, 20)
20f, 0f //(x2, y2)相對(x0, y0)為(20, 0)
)
path.rQuadTo(
10f, -20f, //(x3, y3)相對(x2, y2)為(10, -20)
20f, 0f //(x4, y4)相對(x2, y2)為(20, 0)
)
絕對坐標的每個點都是以坐標系的原點為參考;而相對坐標是以moveTo方法那個點為原點作為參考,如果只調用了一次moveTo方法,而調用了多次rQuadTo方法,那么從第二次rQuadTo方法開始,它參考上一次rQuadTo方法的最后一個坐標值,例如上面相對坐標計算中,第二次rQuadTo方法的(x3, y3),(x4, y4)是參考(x2, y2)計算出來的,而不是參考(x0, y0)。
上面是為了講解方便把坐標系說成x軸向右,y軸向上,但是在android中,坐標系是x軸向右,y軸向下,原點是View的左上角,這一點要注意。
實現步驟
下面開始講主要的實現步驟:
1、測量控件大小
我使用一個Shape枚舉表示控件的4種形狀,如下:
enum class Shape{
CIRCLE,//圓形,默認形狀
SQUARE, //正方形
RECT, //矩形
NONE//沒有形狀約束
}
對于圓形和正方形,控件的測量寬和高應該保持一樣的,而對于矩形和NONE,控件的測量寬和高可以不一樣,如下:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val measureWidth = MeasureSpec.getSize(widthMeasureSpec)
val measureHeight = MeasureSpec.getSize(heightMeasureSpec)
when(shape){
Shape.CIRCLE, Shape.SQUARE -> {//圓形或正方形
val measureSpec = if(measureHeight < measureWidth) heightMeasureSpec else widthMeasureSpec
//傳入的measureSpec一樣
super.onMeasure(measureSpec, measureSpec)
}else -> {//矩形或NONE
//傳入的measureSpec不一樣
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
}
}
所以如果用戶使用圓形或正方形,但是輸入的寬高不一樣,我就取寬和高的最小值的測量模式去測量控件,這樣就保證了控件的測量寬高一樣;而用戶如果使用矩形或NONE,就保持原來的測量就行了。
一個控件有可能經過多次measure后才確定測量寬高,在多次onMeasure()方法調用后,接下來會調用onSizeChanged()方法,且只會調用一次,這個方法調用后接下來就會調用onLayout()方法確定控件的最終寬高,我在onSizeChanged()里面獲取測量寬高確定了控件作畫的范圍大小和暫時的控件大小,如下:
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
//控件作畫的范圍大小
canvasWidth = measuredWidth
canvasHeight = measuredHeight
//控件大小,暫時等于canvas大小,后面在onlayout()中會改變
viewWidth = canvasWidth
viewHeight = canvasHeight
//...
}
控件作畫的范圍大小和控件大小關系如下:
綠色框就是控件作畫的范圍大小,紅色框就是控件大小,也就是說每次控件大小確定之后,我只取中間的部分繪制,很多人會有疑問?為什么只取中間的部分繪制,而不在整個控件范圍繪制?這是因為當控件的父布局是ConstraintLayout,控件寬或高取match_parent時,會出現以下情況:
藍色框就是手機屏幕,黑色背景就是控件大小,你還記得我上面在onMeasure()方法講過,如果控件的形狀是圓形,那么控件的測量寬高應該相等的,并取最小值為基準,所以如果控件大小輸入是layout_width = "match_parent" ,layout_height = "200dp" 或 layout_width = "200dp" ,layout_height = "match_parent",經過測量后控件大小應該是寬 = 高 = 200dp,效果應該都是如下圖:
可實際情況卻不是圖3,而是圖1或圖2,這是因為ConstraintLayout布局會讓子控件的setMeasuredDimension()失效,所以導致 measuredHeight 和 height 不一樣,寬同理,所以在遇到父布局是ConstraintLayout時,并且控件的寬或高設置了“match_parent”,并且你自定義了測量過程,就會導致自定義View過程中測量出來大小不等于View最終大小,即getMeasureHeigth()或getMeasureWidth() != getWidth()或getHeigth(),為什么ConstraintLayout就會有這種情況而其他如Linearlayout就沒有?我也不知道,可能需要大家通過源碼了解了,而我的解決辦法就是讓每次作畫的范圍在控件的中心,就像圖1和圖2一樣,這樣就不會那么難看。
2、裁剪畫布形狀
怎么把控件弄成圓形、正方形、矩形這些形狀,如果控件形狀是正方形或矩形,還可以設置圓角,一個方法是通過BitmapShader實現,使用BitmapShader要經過3步:
1、新建Bitmap;
2、以1新建的Bitmap創建一個Canvas,在Canvas上畫出波浪;
3、最后新建一個BitmapShader與1的Bitmap關聯,然后設置給畫筆,用畫筆在onDraw方法傳進來的Canvas上畫一個形狀出來,然后這個形狀就會含有波浪.
但我沒有使用BitmapShader,因為波浪的移動需要開啟一個無限循環動畫,就會不斷的調用onDraw()方法,而在onDraw()方法不斷的新建對象是一個不推薦的做法,雖然Bitmap可以通過recycler()復用,但是還是避免不了每次都要新建Canvas對象, 所以為了減少對象分配,我使用了Canvas的clipPathAPI來把畫布裁剪成我想要的形狀,然后把波浪畫在裁剪后的畫布上,這樣也能實現與BitmapShader同樣的效果,如下:
private fun preDrawShapePath(w: Int, h: Int) {
clipPath.reset()
when (shape) {
Shape.CIRCLE -> { //...
//path路徑為圓形
clipPath.addCircle(
shapeCircle.centerX, shapeCircle.centerY,
shapeCircle.circleRadius,
Path.Direction.CCW
)
}
Shape.SQUARE -> {
//...
//path路徑為正方形或圓角正方形
if (shapeCorner == 0f)
clipPath?.addRect(shapeRect, Path.Direction.CCW)
else
clipPath.addRoundRect(
shapeRect,
shapeCorner, shapeCorner,
Path.Direction.CCW
)
}
Shape.RECT -> {
//...
}
}
}
preDrawShapePath()中根據Shape來add不同的形狀給Path來把這些路徑信息預先保存下來,前面已經講過每次作畫的范圍都在控件的中心,//...省略的都是居中計算,保存好形狀的Path將在onDraw方法中使用,如下:
override fun onDraw(canvas: Canvas?) {
clipCanvasShape(canvas)
//...
}
private fun clipCanvasShape(canvas: Canvas?) {
//調用canvas的clipPath方法裁剪畫布
if (shape != Shape.NONE) canvas?.clipPath(clipPath)
//...
}
在onDraw方法中使用canvas.clipPath()方法傳入Path裁剪畫布,這樣以后作畫的范圍都被限定在這個畫布形狀之內。
3、畫波浪
使用貝塞爾曲線畫波浪,如下:
private fun preDrawWavePath() {
wavePath.reset()
//波長等于畫布的寬度
val waveLen = canvasWidth
//波峰
val waveHeight = (waveAmplitude * canvasHeight).toInt()
//波浪的起始y坐標
waveStartY = calculateWaveStartYbyProcess()
//把path移到起始位置,這里使用了path.moveTo()方法
wavePath.moveTo(-canvasWidth * 2f, waveStartY)
//下面就是畫波浪的過程,都使用了path.rXX()方法,表示把上一次結束點的坐標作為原點,從而簡化計算量
val rang = -canvasWidth * 2..canvasWidth
for (i in rang step waveLen) {
wavePath.rQuadTo(
waveLen / 4f, waveHeight / 2f,
waveLen / 2f, 0f
)
wavePath.rQuadTo(
waveLen / 4f, -waveHeight / 2f,
waveLen / 2f, 0f
)
}
//波浪的深度就是畫布的高度
wavePath.rLineTo(0f, canvasHeight.toFloat())
wavePath.rLineTo(-canvasWidth * 3f, 0f)
//最后使用path.close()把波浪的路徑關閉,使整個波浪圍起來
wavePath.close()
}
preDrawWavePath() 中把波浪路徑的信息保存在path中,下面一張圖很好的說明波浪的整個路徑,如下:
我把控件大小充滿了父容器,所以控件的作畫范圍就是綠色框的大小,波浪的波長就是一個畫布的寬度即綠色框的寬度,我把波浪的起始點移到屏幕范圍外,從起始點開始,畫了三個波長,把波浪畫出屏幕的范圍,從而方便的待會的波浪的上下移動,最后記得使用path.close()把波浪的路徑關閉,使整個波浪圍起來。
保存好波浪路徑的信息的Path在onDraw方法中使用,如下:
override fun onDraw(canvas: Canvas?) {
clipCanvasShape(canvas)
drawWave(canvas)
//...
}
private fun drawWave(canvas: Canvas?) {
wavePaint.style = Paint.Style.FILL_AND_STROKE
wavePaint.color = waveColor
//...
//使用canvas的drawPath()方法把波浪畫在畫布上
canvas?.drawPath(wavePath, wavePaint)
}
使用canvas的drawPath()方法直接把波浪畫在畫布上,這時在屏幕上顯示的效果如下:
這樣就畫出了一條波浪了,第二條波浪呢?可以再用另外一個Path按照上述preDrawWavePath()方法的流程再畫一條,只要波浪的起始點坐標不同就行,但我沒有用這種辦法,我是通過Canvas的translate()方法平移畫布,利用兩次平移的偏移量不一樣,畫出了第二條,如下:
private fun drawWave(canvas: Canvas?) {
wavePaint.style = Paint.Style.FILL_AND_STROKE
//首先保存兩次畫布狀態,記為畫布1、2
canvas?.save()//畫布1
canvas?.save()//畫布2
//記當前畫布為畫布3
//調用canvas的translate()方法水平平移一下畫布3
canvas?.translate(canvasSlowOffsetX, 0)
wavePaint.color = adjustAlpha(waveColor, 0.7f)
//首先在畫布3畫出第一條波浪
canvas?.drawPath(wavePath, wavePaint)
//恢復保存的畫布2狀態
canvas?.restore()
//下面是在畫布2上作畫
//調用canvas的translate()方法水平平移一下畫布2
canvas?.translate(canvasFastOffsetX, 0)
wavePaint.color = waveColor
//然后在畫布2上畫出第二條波浪
canvas?.drawPath(wavePath, wavePaint)
//恢復保存的畫布1狀態
canvas?.restore()
//后面都是在畫布1上作畫
}
熟悉Canvas的save()、restore()方法都知道,每調用一次save(),可以理解為畫布的一次入棧(保存),每調用一次restore(),可以理解為畫布的出棧(恢復),畫布3是默認就有的,畫布1、2是我保存生成的,所以上述畫布1,2,3之間是獨立的,互不影響的,而canvasSlowOffsetX和canvasFastOffsetX兩個值是不一樣的,這樣就造成了畫布2和3平移時偏移量不一樣,所以用同一個Path畫在兩個偏移量不一樣的畫布上就可以形成兩條波浪,效果圖如下:
4、讓波浪動起來
讓波浪移動起來很簡單,使用一個無限循環動畫,在動畫的進度回調中計算畫布的偏移量,然后調用invalidate()就行,如下:
waveValueAnim.apply {
duration = ANIM_TIME
repeatCount = ValueAnimator.INFINITE//無限循環
repeatMode = ValueAnimator.RESTART
addUpdateListener{ animation ->
//...
canvasFastOffsetX = (canvasFastOffsetX + fastWaveOffsetX) % canvasWidth
canvasSlowOffsetX = (canvasSlowOffsetX + slowWaveOffsetX) % canvasWidth
invalidate()
}
}
在適當的時機啟動動畫,如下:
override fun onDraw(canvas: Canvas?) {
clipCanvasShape(canvas)
drawWave(canvas)
//...
//啟動動畫
startLoading()
}
fun startLoading(){
if(!waveValueAnim.isStarted) waveValueAnim.start()
}
到這里整個控件就完成了。
5、優化
大家都知道手機的資源都是非常有限的,我在做自定義View時,特別是涉及到無限循環的動畫時,要注意優化我們的代碼,因為一般的屏幕刷新周期是16ms,這意味著在這16ms內你要把有關動畫的所有計算和流程完成,不然就會造成掉幀,從而卡頓,在自定義View時我想到可以從下面幾點做一些優化,提高效率:
5.1、減少對象的內存分配,盡可能做到對象復用
每次系統GC的時候都會暫停系統ms級別的時間,而無限循環的動畫的邏輯代碼會在短時間內被循環往復的調用, 這樣如果在邏輯代碼中在堆上創建過多的臨時變量,會導致內存的使用量在短時間內上升,從而頻繁的引發系統的GC行為,這樣無疑會拖累動畫的效率,讓動畫變得卡頓。
在自定義View涉及到無限循環動畫時,我們不能忽略對象的內存分配,不要經常在onDraw()方法中new對象:如果這些臨時變量每次的使用都是固定,完全不需要每次循環執行的時候重復創建,我們可以考慮將它們從臨時變量轉為成員變量,在動畫初始化或View初始化時將這些成員變量初始化好,需要的時候直接調用即可;對于不規則圖形的繪制我們會需要到Path,并且對于越復雜的 Path,Canvas 在繪制的時候,也會更加的耗時,因此我們需要做的就是盡量優化 Path 的創建過程, 還有Path 類中本身提供reset()和rewind()方法用于復用Path對象, reset()方法是用于對象的復位,rewind()方法在對象的復位基礎上還可以讓Path對象不釋放之前已經分配的內存就,重用之前分配的內存。
5.2、抽取重復運算,盡可能減少浮點運算
在自定義View的時候不難免遇到大量的運算,特別在做無限循環動畫時,其邏輯代碼會在短時間內被循環往復的調用, 這樣如果在邏輯代碼中在做過多的重復運算無疑會降低動畫的效率,特別是在做浮點運算時,CPU 在處理浮點運算時候、會變的特別的慢,要多個指令周期才能完成。
因此我們還應該努力減少浮點運算,在不考慮精度的情況下,可以將浮點運算轉成整型來運算,同時我們還應該把重復的運算從邏輯代碼中抽取出來,不用每次都運算,例如在WaveLoadingView中, 我創建Path的過程的計算大部分都是在onLayout()中成,把重復運算的結果提前用Path保存好,然后在onDraw()中使用,因為onDraw()在做動畫時會被頻繁的被調用。
5.3、考慮使用SurfaceView
傳統的View的測量、布局、繪制都是在UI線程中完成的,而Android 的UI線程除了View的繪制之外,還需要進行額外的用戶處理邏輯、輪詢消息事件等,這樣當View的繪制和動畫比較復雜,計算量比較大的情況,就不再適合使用 View 這種方式來繪制了。這時候我們可以考慮使用SurfaceView ,SurfaceView 能夠在非 UI 線程中進行圖形繪制,釋放了 UI 線程的壓力。當然WaveLoadingView也可以使用SurfaceView 來實現。
結語
WaveLoadingView的實現就講解完畢,本次自定義View的過程都使用了kotlin進行編寫,整體的代碼量的確比java的減少了許多,但語言畢竟只是一個工具,我們主要是學習自定義View的實踐過程,當你經常動手實踐后,你會發現自定義View沒有想象那么難,來來去去就那幾個方法,大部分時間都是花在實現的細節和運算上,更多實現請查看文末地址。