本文是自定義View實踐第二篇,上一篇仿微信滑動按鈕實現了一個簡單的滑動按鈕,知道了一些自定義View的基本步驟,本文是使用貝塞爾曲線實現的一個加載中控件,因此閱讀本文前你須要具有貝塞爾曲線的知識,懂得使用Android中相關的API。接下來進入正文講解。java
以前項目一直使用這個WaveLoadingView來做爲loading控件,我看它的波浪實現覺得它的內部是使用貝塞爾曲線實現,直到某一天我看到它的源碼時才發現不是,它的波浪實現也就是曲線實現實際上是使用一個正弦函數y = Asin(ωx + φ) + h畫出來的,當φ取兩個不一樣的值,而A、ω、h保持相等時,就能夠造成兩條偏移量不一樣的正弦曲線,相似下面兩條正弦曲線(一個φ等於0,一個φ等於2):git
當造成兩條曲線後,畫在新建的Bitmap畫布上,而後使用設置了BitmapShader的Paint畫在最終的Canvas上就造成了有固定形狀的兩條波浪,當你不斷的改變φ值時就能夠不斷的移動波浪 。關於BitmapShader的原理自行查找文章,它簡單的來講就是使用Bitmap來填充Paint,這樣使用Paint的時候就能夠指定Bitmap畫在Canvas上的形狀。github
然而正弦函數的知識我早已經還給了老師,並且使用正弦函數畫曲線的計算有點複雜,因此爲了簡化計算量,我就使用貝塞爾曲線代替正弦函數從新實現了一遍這個WaveLoadingView,名字仍是叫WaveLoadingView,見下面兩個WaveLoadingView的對比圖:canvas
左邊是我實現的WaveLoadingView,右邊是原來的WaveLoadingView,除了默認的顏色不同,它們的效果基本都同樣,對於WaveLoadingView不一樣形狀的實現,我沒有使用BitmapShader,而是使用了Canvas的相關畫布裁剪API,至於爲何不用BitmapShader,見下面實現分析。微信
交代了一下WaveLoadingView的背景,下面開始講主要的實現步驟:app
我使用一個Shape枚舉表示控件的4種形狀,以下:ide
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時,以下:
控件大小:layout_width = "match_parent" ; layout_height = "200dp"
圖1
控件大小:layout_width = "200dp" ; layout_height = "match_parent"
圖2
藍色框就是手機屏幕,黑色背景就是控件大小,你還記得我上面在onMeasure()方法講過,若是控件的形狀是圓形,那麼控件的測量寬高應該相等的,並取最小值爲基準,因此若是控件大小輸入是layout_width = "match_parent" ; layout_height = "200dp" 或 layout_width = "200dp" ; layout_height = "match_parent",通過測量後控件大小應該是寬 = 高 = 200dp,效果應該都是以下圖:
圖3
可實際狀況卻不是圖3,而是圖1或圖2,這是由於ConstraintLayout佈局會讓子控件的setMeasuredDimension()失效,因此致使 measuredHeight 和 height 不同,寬同理。因此在遇到父佈局是ConstraintLayout時,而且控件的寬或高設置了「match_parent」,而且你自定義了測量過程,就會致使自定義View過程當中測量出來大小不等於View最終大小,即getMeasureHeigth()或getMeasureWidth() != getWidth()或getHeigth()。
爲何ConstraintLayout就會有這種狀況而其餘如Linearlayout就沒有?我也不知道,可能須要你們經過源碼瞭解了,而我是遇到了這種狀況,就經過本身的辦法解決,解決辦法就是讓每次做畫的範圍在控件的中心,就像圖1和圖2同樣,這樣就不會那麼難看。
怎麼把控件弄成圓形、正方形、矩形這些形狀,若是控件形狀是正方形或矩形,還能夠設置圓角,一個方法是經過BitmapShader實現,使用BitmapShader要通過3步:一、新建Bitmap;二、以1新建的Bitmap新建一個Canvas;三、在2新建的Canvas上畫出波浪,而後新建一個BitmapShader與1的Bitmap關聯,但我沒有使用BitmapShader,由於波浪的移動須要開啓一個無限循環動畫,就會不斷的調用onDraw()方法,而在onDraw()方法不斷的新建對象是一個不推薦的作法,雖然Bitmap能夠經過recycler()複用,可是仍是避免不了每次都要新建Canvas對象。
因此爲了減小對象分配,我使用了Canvas的clipPath()API來把畫布裁剪成我想要的形狀,而後把波浪畫在裁剪後的畫布上,這樣也能實現與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裁剪畫布,這樣之後做畫的範圍都被限定在這個畫布形狀以內。
使用貝塞爾曲線畫波浪,以下:
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
//首先保存兩次畫布狀態,記爲畫布一、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是默認就有的,畫布一、2是我保存生成的,因此上述畫布1,2,3之間是獨立的,互不影響的,而canvasSlowOffsetX和canvasFastOffsetX兩個值是不同的,這樣就形成了畫布2和3平移時偏移量不同,因此用同一個Path畫在兩個偏移量不同的畫布上就能夠造成兩條波浪,效果圖以下:
讓波浪移動起來很簡單,使用一個無限循環動畫,在動畫的進度回調中計算畫布的偏移量,而後調用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()
}
複製代碼
到這裏整個控件就完成了。
你們都知道手機的資源都是很是有限的,我在作自定義View時,特別是涉及到無限循環的動畫時,要注意優化咱們的代碼,由於通常的屏幕刷新週期是16ms,這意味着在這16ms內你要把有關動畫的全部計算和流程完成,否則就會形成掉幀,從而卡頓,在自定義View時我想到能夠從下面幾點作一些優化,提升效率:
每次系統GC的時候都會暫停系統ms級別的時間,而無限循環的動畫的邏輯代碼會在短期內被循環往復的調用, 這樣若是在邏輯代碼中在堆上建立過多的臨時變量,會致使內存的使用量在短期內上升,從而頻繁的引起系統的GC行爲,這樣無疑會拖累動畫的效率,讓動畫變得卡頓。
在自定義View涉及到無限循環動畫時,咱們不能忽略對象的內存分配,不要常常在onDraw()方法中new對象:若是這些臨時變量每次的使用都是固定,徹底不須要每次循環執行的時候重複建立,咱們能夠考慮將它們從臨時變量轉爲成員變量,在動畫初始化或View初始化時將這些成員變量初始化好,須要的時候直接調用便可;對於不規則圖形的繪製咱們會須要到Path,而且對於越複雜的 Path,Canvas 在繪製的時候,也會更加的耗時,所以咱們須要作的就是儘可能優化 Path 的建立過程, 還有Path 類中自己提供reset()和rewind()方法用於複用Path對象, reset()方法是用於對象的復位,rewind()方法在對象的復位基礎上還可讓Path對象不釋放以前已經分配的內存就,重用以前分配的內存。
在自定義View的時候不不免遇到大量的運算,特別在作無限循環動畫時,其邏輯代碼會在短期內被循環往復的調用, 這樣若是在邏輯代碼中在作過多的重複運算無疑會下降動畫的效率,特別是在作浮點運算時,CPU 在處理浮點運算時候、會變的特別的慢,要多個指令週期才能完成。
所以咱們還應該努力減小浮點運算,在不考慮精度的狀況下,能夠將浮點運算轉成整型來運算,同時咱們還應該把重複的運算從邏輯代碼中抽取出來,不用每次都運算,例如在WaveLoadingView中, 我建立Path的過程的計算大部分都是在onLayout()中成,把重複運算的結果提早用Path保存好,而後在onDraw()中使用,由於onDraw()在作動畫時會被頻繁的被調用。
傳統的View的測量、佈局、繪製都是在UI線程中完成的,而Android 的UI線程除了View的繪製以外,還須要進行額外的用戶處理邏輯、輪詢消息事件等,這樣當View的繪製和動畫比較複雜,計算量比較大的狀況,就再也不適合使用 View 這種方式來繪製了。這時候咱們能夠考慮使用SurfaceView ,SurfaceView 可以在非 UI 線程中進行圖形繪製,釋放了 UI 線程的壓力。固然WaveLoadingView也可使用SurfaceView 來實現。
WaveLoadingView的實現就講解完畢,更多實現查看文末地址,本次自定義View的過程都使用了kotlin進行編寫,總體的代碼量的確比java的減小了許多,但語言畢竟只是一個工具,咱們主要是學習自定義View的實踐過程,當你常常動手實踐後,你會發現自定義View沒有想象那麼難,來來去去就那幾個方法,大部分時間都是花在實現的細節和運算上,因此下一次就是自定義ViewGroup實踐了。