Android 自定義 View 之 LeavesLoading
前天的瀏覽 GitHub 時發現一個模仿 Gif 的 Loading 特效的專案,感覺效果很不錯,也比較有創意,如下:

GitHub 上好幾個做這個效果的專案,但是很少有完全實現的,有的還有 Bug,於是花了 2 天實現了一下。
效果如下:

GitHub 專案在這裡 LeavesLoading
2. 分析
實現要求:
- 葉子
- 隨機產生
- 飄動軌跡為正弦函式,並且隨機振幅
- 飄動時伴隨自旋轉,更符合物理規律
- 遇到進度條似乎是融入的
- 風扇
- 可旋轉
- Loading == 100% 時顯示一個動畫
- 細節
- 風扇和葉子自適應 View 大小
- 葉子在視覺上不能飄出 RountRect 邊界
3. 核心實現
3.1 隨機產生葉子
本質是事先產生一定數量葉子,這些葉子的漂動時的振幅、相位、旋轉方向等等都是隨機的,並且飄動是週期性地即葉子飄動到最左邊時,又重新回到最右邊。
Leaf 類:
private class Leaf{ float x,y;//座標 AmplitudeType type;//葉子飄動振幅 int rotateAngle;//旋轉角度 RotateDir rotateDir;//旋轉方向 long startTime;//起始時間 int n;//初始相位 } 複製程式碼
Leaf 生成方法:
Leaf generateLeaf(){ Leaf leaf = new Leaf(); //隨機振幅 int randomType = mRandom.nextInt(3); switch (randomType){ case 0: //小振幅 leaf.type = AmplitudeType.LITTLE; break; case 1: //中等振幅 leaf.type = AmplitudeType.MIDDLE; break; default: //大振幅 leaf.type = AmplitudeType.BIG; break; } //隨機旋轉方向 int dir = mRandom.nextInt(2); switch (dir){ case 0: //逆時針 leaf.rotateDir = RotateDir.ANTICLOCKWISE; break; default: //順時針 leaf.rotateDir = RotateDir.CLOCKWISE; break; } //隨機起始角度 leaf.rotateAngle = mRandom.nextInt(360); leaf.n = mRandom.nextInt(20); mAddTime += mRandom.nextInt((int)mLeafFloatTime); leaf.startTime = System.currentTimeMillis() + mAddTime; return leaf; } 複製程式碼
3.2 葉子飄動軌跡為正弦函式
確定 Leaf 在某個時刻的座標 ( x , y ):
/** * 獲取葉子的(x,y)位置 * @param leaf 葉子 * @param currentTime 當前時間 */ private void getLeafLocation(Leaf leaf,long currentTime){ long intervalTime = currentTime - leaf.startTime;//飄動時長 if (intervalTime <= 0){ // 此 Leaf 還沒到飄動時間 return; }else if (intervalTime > mLeafFloatTime){ // Leaf 的飄動時間大於指定的飄動時間,即葉子飄動到了最左邊,應回到最右邊 leaf.startTime = currentTime + new Random().nextInt((int)mLeafFloatTime); } // 計算移動因子 float fraction = (float) intervalTime / mLeafFloatTime; leaf.x = (1-fraction)*mProgressLen; leaf.y = getLeafLocationY(leaf); if (leaf.x <= mYellowOvalHeight / 4){ //葉子飄到最左邊,有可能會超出 RoundRect 邊界,所以提前特殊處理 leaf.startTime = currentTime + new Random().nextInt((int)mLeafFloatTime); leaf.x = mProgressLen; leaf.y = getLeafLocationY(leaf); } } 複製程式碼
要想讓 Leaf 飄動軌跡為正弦函式,關鍵在於確定 Leaf 的 Y 軸座標:
/** * 獲取葉子的Y軸座標 * @param leaf 葉子 * @return 經過計算的葉子Y軸座標 */ private float getLeafLocationY(Leaf leaf){ float w = (float) (Math.PI * 2 / mProgressLen);//角頻率 float A;//計算振幅值 switch (leaf.type){ case LITTLE: A = mLeafLen/3; break; case MIDDLE: A = mLeafLen*2/3; break; default: A = mLeafLen; break; } // (mHeight-mLeafLen)/2 是為了讓 Leaf 的Y軸起始位置居中 return (float) (A * Math.sin(w * leaf.x + leaf.n)+(mHeight-mLeafLen)/2); } 複製程式碼
3.3 葉子飄動時自旋轉
這裡就涉及到了 Leaf 的繪製,其實 Gif 中的葉子和風扇都可以使用 Canves 直接繪製圖案,但是這樣就會有兩個問題:
- 難畫:想要畫出滿意圖形,並且還要旋轉、縮放、平移可要下一番功夫。
- 靈活性低:如果想換其他樣式又得重新設計繪製過程。
因此這裡採用 Canves.drawBitmap()
的方式繪製,直接使用已有的圖片作為葉子和風扇,同時利用 Canves.drawBitmap()
的一個過載的方法可以很方便的實現旋轉、縮放、平移:
void drawBitmap(Bitmap bitmap,Matrix matrix, Paint paint) ; 複製程式碼
就是通過這裡的 Matrix 矩陣,它內部封裝了 postScale()
、 postTranslate
、 postRotate()
等方法,可以幫助我們快速的對 Bitmap 進行旋轉、縮放、平移還有其他操作。使用時要記得配合 Canves 的 save()
和 restore()
使用,否則達不到想要的效果。
對這方面不熟的朋友可以看看 HenCoder 的自定義 View 教學 1-4 。
繪製 Leaf 的方法:
private void drawLeaves(Canvas canvas){ long currentTime = System.currentTimeMillis(); for (Leaf leaf : mLeafList) { if (currentTime > leaf.startTime && leaf.startTime != 0){ // 獲取 leaf 當前的座標 getLeafLocation(leaf,currentTime); canvas.save(); Matrix matrix = new Matrix(); // 縮放 自適應 View 的大小 float scaleX = (float) mLeafLen / mLeafBitmapWidth; float scaleY = (float) mLeafLen / mLeafBitmapHeight; matrix.postScale(scaleX,scaleY); // 位移 float transX = leaf.x; float transY = leaf.y; matrix.postTranslate(transX,transY); // 旋轉 // 計算旋轉因子 float rotateFraction = ((currentTime - leaf.startTime) % mLeafRotateTime) /(float)mLeafRotateTime; float rotate; switch (leaf.rotateDir){ case CLOCKWISE: //順時針 rotate = rotateFraction * 360 + leaf.rotateAngle; break; default: //逆時針 rotate = -rotateFraction * 360 + leaf.rotateAngle; break; } // 旋轉中心選擇 Leaf 的中心座標 matrix.postRotate(rotate,transX + mLeafLen / 2,transY + mLeafLen / 2); canvas.drawBitmap(mLeafBitmap,matrix,mBitmapPaint); canvas.restore(); } } 複製程式碼
3.4 Loading == 100% 出現動畫
增加一個判斷欄位 isLoadingCompleted ,在 onDraw()
中選擇對應繪製策略。
isLoadingCompleted 在 setProgress()
中根據 progress 設定:
/** * 設定進度(自動重新整理) * @param progress 0-100 */ public void setProgress(int progress){ if (progress < 0){ mProgress = 0; }else if (progress > 100){ mProgress = 100; }else { mProgress = progress; } if (progress == 100){ isLoadingCompleted = true; }else { isLoadingCompleted = false; } // 255 不透明 mCompletedFanPaint.setAlpha(255); postInvalidate(); } 複製程式碼
LeavesLoading.onDraw()
部分實現:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); ...... if (isLoadingCompleted){ //繪製載入完成特效 drawCompleted(canvas); }else { //繪製扇葉 drawFan(canvas,mFanLen,mBitmapPaint); } //重新整理 postInvalidate(); } 複製程式碼
drawCompleted()
實現:
private void drawCompleted(Canvas canvas) { // 每次繪製風扇透明度遞減10 int alpha = mCompletedFanPaint.getAlpha() - 10; if (alpha <= 0){ alpha = 0; } mCompletedFanPaint.setAlpha(alpha); // 文字透明度剛好與風扇相反 mCompletedTextPaint.setAlpha(255-alpha); // 計算透明因子 float fraction = alpha / 255f; // 葉片大小 和 文字大小 也是相反變化的 float fanLen = fraction * mFanLen; float textSize = (1 - fraction) * mCompletedTextSize; mCompletedTextPaint.setTextSize(textSize); //測量文字佔用空間 Rect bounds = new Rect(); mCompletedTextPaint.getTextBounds( LOADING_COMPLETED, 0, LOADING_COMPLETED.length(), bounds); // 與 drawLeaf() 相似,不再贅述 drawFan(canvas, (int) fanLen, mCompletedFanPaint); //畫文字 canvas.drawText( LOADING_COMPLETED, 0, LOADING_COMPLETED.length(), mFanCx-bounds.width()/2f, mFanCy+bounds.height()/2f, mCompletedTextPaint); } 複製程式碼
流程:計算風扇和文字透明度 -> 計算風扇和文字大小以及文字佔用空間 -> 繪製 ,註釋寫得比較清楚就不贅述了。