封面預覽 前言 使用百度貼吧客戶端的時候發發現載入的小動畫挺有意思的,於是自己動手寫寫看。想學習自定義View以及自定義動畫的小伙伴一定不要錯過哦。 讀者朋友需要有最基本的canvas繪圖功底,比如畫筆Paint的簡單使用、Path如何畫直線等簡單的操作,不熟悉也沒關係,下文帶大家擼代碼的時候會簡單 ...
封面預覽
前言
使用百度貼吧客戶端的時候發發現載入的小動畫挺有意思的,於是自己動手寫寫看。想學習自定義View以及自定義動畫的小伙伴一定不要錯過哦。
讀者朋友需要有最基本的canvas繪圖功底,比如畫筆Paint的簡單使用、Path如何畫直線等簡單的操作,不熟悉也沒關係,下文帶大家擼代碼的時候會簡單的講一下。
此篇文章用到如下知識點:
- 1)、自定義View的測量
- 2)、自定義View屬性的自定義及使用
- 3)、Path繪製貝塞爾曲線
- 4)、Canvas的裁剪
- 5)、用ValueAnimator控制動畫
- 6)、Canvas文字居中
好了,開始正文!
一、準備工作
1、效果圖
loading小球
2、動畫拆解
直觀的看我們要實現三個方面
1)、波浪動畫(藍色部分)
2)、不規則的文字(白色的半個“貼”字)
3)、控制項顯示部分限製成圓形
3、技術分析
1)、波浪動畫
要實現波浪動畫,首先要繪製出波浪的形狀,其次再讓他動起來。波浪線看起來有點像是正弦或者餘弦函數,但是Android的Path並沒有提供繪製正餘弦圖形的函數,但是提供了一個功能更強大的曲線——貝塞爾曲線,貝塞爾曲線分為二階、三階及多階,本案例里使用的是二次貝塞爾曲線,如下圖所示,二階貝塞爾曲線需要三個點才可以確定
二階貝塞爾曲線
我們來看一下Android里貝塞爾曲線的源碼:
- /* @param x1 The x-coordinate of the control point on a quadratic curve
- * @param y1 The y-coordinate of the control point on a quadratic curve
- * @param x2 The x-coordinate of the end on a quadratic curve
- * @param y2 The y-coordinate of the end point on a quadratic curve
- */
- public void quadTo(float x1, float y1, float x2, float y2) {
- isSimplePath = false;
- native_quadTo(mNativePath, x1, y1, x2, y2);
- }
由註解可以看出來quadTo(float x1, float y1, float x2, float y2)的四個參數分別是控制點的x,y坐標,結束點的x,y坐標,少了一個開始點呀!不要著急,開始點是Path路徑的上一次結束的點,如果你的Path沒有繪製過路徑,那麼Path的最後一個點坐標就是(0,0)如果想自己定義起始點位置,就用Path.moveTo(float x, float y)即可。
但是每次都需要指定具體的控制點和結束點既麻煩又容易出錯,那麼就需要rQuadTo(float dx1, float dy1, float dx2, float dy2)出馬了,rQuadTo跟quadTo的區別在於rQuadTo使用的是相對起始點的坐標,而不是具體的坐標點,舉個例子,如下代碼效果等價:
- //使用quadTo
- Path path=new Path();
- path.moveTo(100,100);
- path.quadTo(150,0,200,100);
- //使用rQuadTo
- Path path=new Path();
- path.moveTo(100,100);
- path.rQuadTo(50,-100,100,0);
此時畫筆最後的落點都為(200,100)。
畫波浪線的技術難點解決了那麼如何讓波浪動起來呢,想動起來肯定需要波浪在水平方向移動,那麼我們需要畫一個很長很長的波浪讓他移動,這樣就實現了上下起伏效果,但是這樣需要畫無數多條貝塞爾曲線,肯定不行,這時就用到萬能的數學理論——周期函數了,如果我們繪製兩個周期的貝塞爾曲線,每次只讓它顯示一個周期,然後等第二周期顯示結束的時候再從頭開始,這樣就造成了無限周期的假象,如下圖
初始位置為1,向右前進,當走到2位置的時候重置成3的位置,即1原始的位置,如此往複就成了綿綿不絕的波浪了
綿延原理
做成效果如下:黃色區域就是要顯示的區域,藍色豎線是波浪線兩個周期的總長度
連綿不絕的波浪線
2)、不規則的文字
我們可以看到圓球里的“貼”字在波浪區域顯示的是白色,波浪區域之外顯示的是藍色,Android並不支持給文字部分區域著色的功能,那麼我們只能靠控制顯示區域讓文字只顯示特定形狀,強大的Canvas正好有畫布裁剪功能,通過裁剪畫布就能控制繪製區域,畫布的裁剪可以用Canvas.clipPath(Path path)實現,傳入一個閉合的Path既可以隨心所欲裁剪畫布,裁剪示意圖如下
裁剪文字
利用波浪形閉合路徑講畫布裁剪成波浪形,那麼在此接下來的Canvas繪製操的內容只能在這個波浪形區域里顯示,這樣就解決了文字的部分區域顯示問題。那麼接下來我們只用在相同位置繪製相同字體、字型大小不同色的文字即可實現一個文字顯示兩種顏色了(註意:實際操作的時候,被裁剪的文字要蓋在未被裁減的文字的上邊,即先在畫布裁剪之前繪製藍色的“貼”字,然後再裁剪畫布再在裁剪後的畫布上繪製白色的“貼”)
3)、控制項顯示部分限製成圓形
經過2)的分析,將顯示部分限制在圓形區域里不是易如反掌嗎,使用一個圓形的Path裁剪畫布即可。感興趣的同學也可以嘗試BitmapShader或者Xfermode來將顯示區域變成圓形
好了,最主要的步驟都分析完了,上一張圖更直觀地展示一下繪製流程
整體分析圖
圖中可以看出波浪形的閉合Path有兩個作用,一個是負責裁剪畫布,一個是負責繪製藍色,其實只用第一個功能即可,此處只是方便分解步驟。
二、代碼實現
文章只貼出主要代碼,完整代碼文末提供鏈接
既然是自定義控制項,那就要有通用性比如下邊的效果:
各種顏色的球球
loading小球需文字和顏色都可以改變,所以我們要給自己的控制項添加這兩個屬性。首先在“res/values/”路徑下新建一個attrs.xml文件,在裡邊定義如下屬性:
- <declare-styleable name="Wave">
- <attr name="color" format="color"/>
- <attr name="text" format="string"/>
- </declare-styleable>
接下來開始自定義View
覆寫三個構造函數,將單參數和雙參數的構造函數的super方法都改為this,保證無論調用哪個構造方法都會跳到三個參數的構造方法中,這樣就可以偷懶只用在三個參數的構造方法里初始化各種參數了
- public class Wave extends View {
- public Wave(Context context) {
- this(context,null);
- }
- public Wave(Context context, AttributeSet attrs) {
- this(context, attrs,0);
- }
- public Wave(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- //初始化參數
- init(context,attrs);
- }
- }
接下來是初始化函數,在此處我們獲取到自定義的顏色及文字參數,並初始化各種畫筆,代碼比較簡單,看註釋內容即可
- private void init(Context context, AttributeSet attrs) {
- //獲取自定義參數值
- TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.Wave);
- //自定義顏色和文字
- color = array.getColor(R.styleable.Wave_color, Color.rgb(41, 163, 254));
- text = array.getString(R.styleable.Wave_text);
- array.recycle();
- //圖形及路徑填充畫筆(抗鋸齒、填充、防抖動)
- mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- mPaint.setStyle(Paint.Style.FILL);
- mPaint.setColor(color);
- mPaint.setDither(true);
- //文字畫筆(抗鋸齒、白色、粗體)
- textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- textPaint.setColor(Color.WHITE);
- textPaint.setTypeface(Typeface.DEFAULT_BOLD);
- //閉合波浪路徑
- path = new Path();
- }
接下來是生成波浪線的方法,示意圖如下:
波浪生成原理
將Path起點移動到最左邊粉色點處,然後繪製兩個周期的長度的波形(一上一下是一個周期),每個周期在x軸的跨度為此控制項的寬度控制點距波形的軸線的絕對高度是整個控制項的3/20,當然想讓波形波動幅度大的話這個比例可以隨意調整,接下來就用前邊講到的rQuadTo( )來生成閉合的波浪圖形,其中mWidth為控制項的寬度,mHeight為控制項的高度
- private Path getActionPath(float percent) {
- Path path = new Path();
- int x = -mWidth;
- //當前x點坐標(根據動畫進度水平推移,一個動畫周期推移的距離為一個周期的波長)
- x += percent * mWidth;
- //波形的起點
- path.moveTo(x, mHeight / 2);
- //控制點的相對寬度
- int quadWidth = mWidth / 4;
- //控制點的相對高度
- int quadHeight = mHeight / 20 * 3;
- //第一個周期波形
- path.rQuadTo(quadWidth, quadHeight, quadWidth * 2, 0);
- path.rQuadTo(quadWidth, -quadHeight, quadWidth * 2, 0);
- //第二個周期波形
- path.rQuadTo(quadWidth, quadHeight, quadWidth * 2, 0);
- path.rQuadTo(quadWidth, -quadHeight, quadWidth * 2, 0);
- //右側的直線
- path.lineTo(x + mWidth * 2, mHeight);
- //下邊的直線
- path.lineTo(x, mHeight);
- //自動閉合補出左邊的直線
- path.close();
- return path;
- }
上邊代碼所表示的閉合路徑如下圖
閉合的波浪圖形
接下來就是重頭戲onDraw了
- @Override
- protected void onDraw(Canvas canvas) {
- //底部的字
- textPaint.setColor(color);
- drawCenterText(canvas, textPaint, text);
- //上層的字
- textPaint.setColor(Color.WHITE);
- //生成閉合波浪路徑
- path = getActionPath(currentPercent);
- canvas.clipPath(path);
- //裁剪成圓形
- canvas.drawCircle(mWidth / 2, mHeight / 2, mWidth / 2, mPaint);
- drawCenterText(canvas, textPaint, text);
- }
這裡繪製思路是:在canvas上繪製藍色的文字 ——>將畫布裁剪成波浪形 ——>在波浪形畫布上繪製圓 ——>在波浪形畫布上繪製文字,這裡一定要註意繪製順序,先繪製的在下部,後繪製的在上部。
細心的朋友一定看到了一個函數drawCenterText(canvas, textPaint, text)沒錯,這個函數就是講文字繪於控制項正中心的方法。有的讀者可能一直在使用Canvas.drawText( String text, float x, float y, Paint paint) 這個方法,但是參數中的(x,y)到底是哪個坐標呢,是文字左上角的點的坐標嗎?不是的,接下來我們用代碼驗證一下這個(x,y)到底在文字的哪個部位
- canvas.drawText(text,600,200,textPaint);
- canvas.drawCircle(600,200,3,paint);
- canvas.translate(600, 200);
- Rect bgRect=new Rect(0,0,1000,400);
- canvas.drawRect(bgRect,bgPaint);
- Rect textBound=new Rect();
- textPaint.getTextBounds(text,0,text.length(),textBound);
- paint.setColor(Color.RED);
- canvas.drawRect(textBound,paint);
- Paint.FontMetrics metrics=textPaint.getFontMetrics();
- paint.setColor(Color.RED);
- // ascent 橙色
- paint.setColor(Color.rgb(255,126,0));
- canvas.drawLine(0, metrics.ascent, 500,metrics.ascent, paint);
- // descent
- paint.setColor(Color.rgb(255,0,234));
- canvas.drawLine(0, metrics.descent, 500, metrics.descent, paint);
- // top
- paint.setColor(Color.DKGRAY);
- canvas.drawLine(0, metrics.top, 500, metrics.top, paint);
- // bottom
- paint.setColor(Color.GREEN);
- canvas.drawLine(0, metrics.bottom, 500, metrics.bottom, paint);
首先是在畫布的(600,200)處畫上文字,為了方便觀察(600,200)在文字的什麼部位,我在(600,200)處畫了一個半徑3像素的圓圈。然後平移畫布到(600,200)的地方然後依次畫出了文字的邊框圖以及FontMetrics信息里的top、ascent、descent、bottom信息
我把運行結果截圖做了處理,方便大家看
文字的各個邊界
從結果看(600,200)那個藍色的點並不是在文字的左上角,而是左下角,這個點所在的y坐標即是大家常說的BaseLine的位置,那現在這個函數Canvas.drawText( String text, float x, float y, Paint paint)就可以理解為——將文字的基準點放在(x,y)處,那麼這個基準點可以改變嗎?答案是肯定的,可以通過繪製文字的畫筆的setTextAlign(Align align)方法設置為Paint.Align.CENTER或者Paint.Align.RIGHT,如果不設置的話預設是Paint.Align.LEFT。讀者朋友們有興趣的話可以試試設置成CENTER之後(600,200)的藍圈圈是不是跑到了文字的中部呢?從上圖我們也可以看出,整個文字是介於FontMetrics.top和FontMetrics.bottom之間。
好了,貼上文字居中的代碼,相信認真看上邊那段話的朋友一定能輕鬆讀懂
- private void drawCenterText(Canvas canvas, Paint textPaint, String text) {
- Rect rect = new Rect(0, 0, mWidth, mHeight);
- textPaint.setTextAlign(Paint.Align.CENTER);
- Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
- //文字框最高點距離baseline的距離(負數)
- float top = fontMetrics.top;
- //文字框最低點距離baseline的距離(正數)
- float bottom = fontMetrics.bottom;
- int centerY = (int) (rect.centerY() - top / 2 - bottom / 2);
- canvas.drawText(text, rect.centerX(), centerY, textPaint);
- }
分析好上邊的代碼 我們就能繪製出一個靜態的小球了,動畫既然要動,肯定就像汽車一樣需要一個"引擎",在上面說到的繪製波浪路徑的函數中我們忽略了getActionPath(float percent)的參數percent,這個參數即是當前動畫的進度,那麼我們如何來製造這個進度呢?需要怎樣把這個動畫“引擎”點燃呢。我們可以通過各種手段計時,生成一個計時Thread或者自己寫一個Handler等等,只要能均勻的生成進度即可。
本文中用到一個巧妙的定時器ValueAnimator 大家常說的屬性動畫ObjectAnimator就是它的一個子類,使用它來作為動畫的引擎再方便不過了,從字面翻譯"ValueAnimator"那就是“值動畫者”直譯雖然low但是恰恰更好理解,就是讓數值動起來,從什麼值動到什麼值呢?
- ValueAnimator animator = ValueAnimator.ofFloat(0, 1);
這句話就是定義一個值從0變化到1的一個animator,我們的percent值就是從0變化到1的中間過程值,那麼怎麼得到這個過程值呢?——監聽器!對!
- animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
- @Override
- public void onAnimationUpdate(ValueAnimator animation) {
- float percent = animation.getAnimatedFraction();
- }
- });
那麼數值從0變到1需要多久呢?怎麼能無限重覆呢?重覆的時候是重頭開始還是反轉進行呢?別急下麵三句話就是讓動畫無限重覆,每次從頭開始,一個周期1000毫秒
- animator.setDuration(1000);
- animator.setRepeatCount(ValueAnimator.INFINITE);
- animator.setRepeatMode(ValueAnimator.RESTART);
好了,引擎設置好了,發動
animator.start();
上效果
鬼畜版
WTF!這是什麼鬼,為什麼鬼畜地慢幾拍?
列印出來橫坐標看看
- 07-09 18:18:47.308 E/Jcs: getActionPath: -21
- 07-09 18:18:47.326 E/Jcs: getActionPath: -15
- 07-09 18:18:47.342 E/Jcs: getActionPath: -10
- 07-09 18:18:47.359 E/Jcs: getActionPath: -5
- 07-09 18:18:47.375 E/Jcs: getActionPath: -2
- 07-09 18:18:47.392 E/Jcs: getActionPath: 0
- 07-09 18:18:47.409 E/Jcs: getActionPath: 0
最後幾拍的數值差好像不太對呀!拍拍腦門突然一想,我的動畫不均勻是忘記設置一個均勻的插值器了!哎!
- animator.setInterpolator(new LinearInterpolator());
補上一個線性插值器,整個世界都順暢了
百度Loading小球Github源碼
三、結語
第一次寫文章,不免有些疏漏之處,望多多指教!後續我會不定期更新新的內容,爭取把寫文章當成自己生活的一部分。
後記(2017年7月27日15:02:39)
有不少讀者問到關於小球和邊緣鋸齒的問題,我分別用如下方式實現loading小球
1、Canvas的clip方式限制波浪邊界(本文提到的方法)
2、使用Xfermode方式限制波浪和圓形的邊界
3、用Xfermode方式限制白色文字,用shader方式限制圓形的邊界
下邊是效果預覽圖,代碼已經提交到github上了,講解部分儘快補到此文中
三種方式對比