用Canvas和屬性動畫造一隻萌蠢的“小鬼”
最近沒事的時候想自己寫一個支援下拉重新整理,上拉載入的自定義View。寫著寫著,就覺得最常見的“一個圈轉啊轉”的進度條太普通了。
於是,就想看看有沒有更有趣的一點的載入效果。在GitHub上以”android loading”為關鍵字一搜索,就發現有作者開源了這麼一個庫:
那麼,開源的好處就來了,立刻開啟原始碼瞧一瞧別人是怎麼實現的吧。一看發現沒有藉助任何圖片,而就是通過canvas配合屬性動畫完成的整個效果。
按理說別人造好的輪子,我們直接拿來用就好了。但既然感興趣,為什麼不學習一下別人的思路,自己也來實現一個,從而得到提高呢?
所以,綜合一想,自己也重新來畫一畫這個萌蠢萌蠢的小鬼吧。並通過此文來總結一下整個自定義view的思路和收穫。
(P.S:會借鑑原作者的思路,但具體實現細節會有不同,但思路當然才是最重要的,具體實現選擇自己喜歡的就好)
自定義View的建立
其實說起繪畫,就想起了小時候流行的一個口訣,是畫“丁老頭”的,印象中有“一個丁老頭兒,借我倆煤球兒,我說三天還,他說四天還..”之類的。
其實就是這樣的,如果猛的一下讓我們畫個“老頭兒”出來,我們可能會有點懵逼。但按照口訣那樣一部分一部分的畫,似乎就變得容易多了。
所以,我們也可以模仿這個思路來畫這個小鬼。我們簡單分析一下,可以發現這個小鬼的構成其實就是:頭 + 眼睛 + 身體 + 影子。
那麼,還等什麼呢?趕緊按照這個思路“開畫”吧!首先,我們當然是新建一個類,並讓其繼承View,而名字的話就叫GhostView好了。
onMeasure()
在正式開始“作畫”之前,我們肯定是做好相關的準備工作。比如,先確定好要用多大尺寸的“畫紙”。哈哈,其實也就是完成View的measure工作。
我們知道自定義View的時候,如果使用預設的onMeasure()方法:WRAP_CONTENT也會被當做MATCH_PARENT來測量,所以其實要做的也很簡單:
// View寬高
private int mWidth, mHeight;
// 預設寬高(WRAP_CONTENT)
private int mDefaultWidth = dip2px(120);
private int mDefaultHeight = dip2px(180 );
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec));
}
private int measureWidth(int widthMeasureSpec) {
int specMode = MeasureSpec.getMode(widthMeasureSpec);
int specSize = MeasureSpec.getSize(widthMeasureSpec);
if (specMode == MeasureSpec.EXACTLY) {
mWidth = specSize;
} else if (specMode == MeasureSpec.AT_MOST) {
mWidth = Math.min(mDefaultWidth, specSize);
}
return mWidth;
}
private int measureHeight(int heightMeasureSpec) {
int specMode = MeasureSpec.getMode(heightMeasureSpec);
int specSize = MeasureSpec.getSize(heightMeasureSpec);
if (specMode == MeasureSpec.EXACTLY) {
mHeight = specSize;
} else if (specMode == MeasureSpec.AT_MOST) {
mHeight = Math.min(mDefaultHeight, specSize);
}
return mHeight;
}
非常簡單,思路就是:
- 當View的寬高指定為MATCH_PARENT或者明確的值的時候,就使用實際的值。
- 當View的寬高指定為WRAP_CONTENT時,寬度為預設的120dp,高度則為180dp。
Paint,準備畫筆
顯然,想要畫東西,當然我們還需要畫筆。可以從之前的效果圖裡看到,“小鬼”的整個形象需要三種顏色元素,分別是:
白色的身體、黑色的眼睛、以及灰灰的影子。所以,對應來說,我們也需要準備三支不同顏色的畫筆:
// 畫筆
Paint mBodyPaint, mEyesPaint, mShadowPaint;
private void initPaint() {
mBodyPaint = new Paint();
mBodyPaint.setAntiAlias(true);
mBodyPaint.setStyle(Paint.Style.FILL);
mBodyPaint.setColor(Color.WHITE);
mEyesPaint = new Paint();
mEyesPaint.setAntiAlias(true);
mEyesPaint.setStyle(Paint.Style.FILL);
mEyesPaint.setColor(Color.BLACK);
mShadowPaint = new Paint();
mShadowPaint.setAntiAlias(true);
mShadowPaint.setStyle(Paint.Style.FILL);
mShadowPaint.setColor(Color.argb(60, 0, 0, 0));
}
從“頭”開始,畫個圓腦袋
現在我們的準備工作都做好了,自然就可以正式開始畫這個“小鬼”了。我們先從最容易畫的入手,搞個圓圓的腦袋出來:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawHead(canvas);
}
// 頭部的半徑
private int mHeadRadius;
// 圓心(頭部)的X座標
private int mHeadCentreX;
// 圓心(頭部)的Y座標
private int mHeadCentreY;
// 頭部最左側的座標
private int mHeadLeftX;
// 頭部最右側的座標
private int mHeadRightX;
// 距離View頂部的內邊距
private int mPaddingTop = dip2px(20);
private void drawHead(Canvas canvas) {
mHeadRadius = mWidth / 3;
mHeadCentreX = mWidth / 2;
mHeadCentreY = mWidth / 3 + mPaddingTop;
mHeadLeftX = mHeadCentreX - mHeadRadius;
mHeadRightX = mHeadCentreX + mHeadRadius;
canvas.drawCircle(mHeadCentreX, mHeadCentreY, mHeadRadius, mBodyPaint);
}
程式碼同樣很簡單,事實上現在我們就可以使用這個自定義View,來看一看目前為止的效果了:
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorPrimary">
<me.rawn_hwang.ghostdrawer.Ghost
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:background="@color/colorAccent" />
</RelativeLayout>
。。。。。。。。好吧,目前為止我們還看不到任何“萌蠢小鬼”的跡象。沒關係,一步一步的來。
誰說“鬼”就沒影子
有了小鬼的頭之後,我們接著做什麼呢?正常來說我們應該想著接著畫身體。但是從之前的效果圖我們可以看到,小鬼的底部是有一個影子的。
所以,個人選擇先畫這個影子。因為:影子位於View的底部,先完成影子的繪畫,之後更方面我們確定小鬼身體的高度和位置。
其實所謂的影子也非常的簡單,就是一個灰濛濛的“橢圓形”而已:
// 影子所佔區域
private RectF mRectShadow;
// 小鬼身體和影子之間的舉例
private int paddingShadow;
private void drawShadow(Canvas canvas) {
paddingShadow = mHeight / 10;
mRectShadow = new RectF();
mRectShadow.top = mHeight * 8 / 10;
mRectShadow.bottom = mHeight * 9 / 10;
mRectShadow.left = mWidth / 4;
mRectShadow.right = mWidth * 3 / 4;
canvas.drawArc(mRectShadow, 0, 360, false, mShadowPaint);
}
這個時候,我們再來看一看效果變成了什麼樣子:
重頭戲,加上身體
現在,我們就來到了最關鍵的部分了:為小鬼加上身體。其實總的來說,小鬼的身體就是在頭部大約半圓的位置,分別畫上兩條帶有弧度的延長線。
但是,怎麼才能讓小鬼身體的這兩條線與頭部比較完美的融合呢?原作者在這裡使用了一些正弦、餘弦的公式來計算圓的弧度,從而完成了需要。
然而,悔不及當初沒有好好唸書啊,患上了暈“數學公式”的病。所以我機智的選擇用另一種方法,雖然沒那麼高大上,但是比較簡單。就像下面這樣:
private Path mPath = new Path();
// 小鬼身體胖過頭部的寬度
private int mGhostBodyWSpace;
private void drawBody(Canvas canvas) {
mGhostBodyWSpace = mHeadRadius * 2 / 15;
// 先畫右邊的身體
mPath.moveTo(mHeadLeftX, mHeadCentreY);
mPath.lineTo(mHeadRightX, mHeadCentreY);
mPath.quadTo(mHeadRightX + mGhostBodyWSpace, mRectShadow.top - paddingShadow,
mHeadRightX - mGhostBodyWSpace, mRectShadow.top - paddingShadow);
canvas.drawPath(mPath,mBodyPaint);
}
上圖中左邊的部分就是我們目前為止得到的效果;而右邊就是通過把畫筆設定為stroke來解釋這樣做的原理,實際上就是:先通過lineTo在小鬼頭部的中間畫一條直徑,這個時候path的LastPoint就到了最右邊的這個點,然後我們從這個點在右邊向下畫一條二階貝塞爾曲線,就有了小鬼右邊身體的輪廓了。
那麼接著我們該做什麼呢?回憶一下,我們發現小鬼的身體下方是有“波紋”的,就想裙子的褶皺一樣,所以我們現在就給添上裙子。
其實原理仍然很簡單,這個時候path的LastPoint也已經移動到了小鬼右邊身體的下面,我們從這裡開始向左不斷畫多個貝塞爾曲線形成裙褶就行了:
// 單個裙褶的寬高
private int mSkirtWidth, mSkirtHeight;
// 裙褶的個數
private int mSkirtCount = 7;
private void drawBody(Canvas canvas) {
mGhostBodyWSpace = mHeadRadius * 2 / 15;
mSkirtWidth = (mHeadRadius * 2 - mGhostBodyWSpace * 2) / mSkirtCount;
mSkirtHeight = mHeight / 16;
// ......
// 從右向左畫裙褶
for (int i = 1; i <= mSkirtCount; i++) {
if (i % 2 != 0) {
mPath.quadTo(mHeadRightX - mGhostBodyWSpace - mSkirtWidth * i + (mSkirtWidth / 2), mRectShadow.top - paddingShadow - mSkirtHeight,
mHeadRightX - mGhostBodyWSpace - (mSkirtWidth * i), mRectShadow.top - paddingShadow);
} else {
mPath.quadTo(mHeadRightX - mGhostBodyWSpace - mSkirtWidth * i + (mSkirtWidth / 2), mRectShadow.top - paddingShadow + mSkirtHeight,
mHeadRightX - mGhostBodyWSpace - (mSkirtWidth * i), mRectShadow.top - paddingShadow);
}
}
canvas.drawPath(mPath,mBodyPaint);
}
可以看到到了現在,基本就能看見整個小鬼的輪廓了,但我們注意到小鬼左邊似乎有點僵硬。沒關係,我們也給他加上一點對應的弧度就行了:
mPath.quadTo(mHeadLeftX - mGhostBodyWSpace, mRectShadow.top - paddingShadow, mHeadLeftX, mHeadCentreY);
畫“鬼”點睛
到了現在,我們的繪圖工作其實基本就已經完成了。但眼睛是心靈的窗戶,少了眼睛,這個小鬼看上去有點四不像的感覺。趕緊加上眼睛吧!
同樣的,眼睛的繪製其實也非常簡單,就在先要的位置,畫上兩個黑色的小圓就可以了:
private void drawEyes(Canvas canvas) {
canvas.drawCircle(mHeadCentreX , mHeadCentreY, mHeadRadius / 6, mEyesPaint);
canvas.drawCircle(mHeadCentreX + mHeadRadius / 2, mHeadCentreY, mHeadRadius / 6, mEyesPaint);
}
現在我們所有的繪製工作就完成了,把之前粉紅色的背景顏色去掉,再看看效果,是不是有點呆萌的趕腳了呢?
讓小鬼動起來
現在小鬼我們已經畫完了,剩下的工作自然就是讓它動起來,別死氣沉沉的。而我們已經知道了,這個工作就是通過屬性動畫來完成的。
那麼,我們可以新增一個最簡單的位移動畫,比如說這樣做:
private void startAnim(){
ObjectAnimator animator = ObjectAnimator.ofFloat(this,"translationX",0,500);
animator.setRepeatMode(ObjectAnimator.RESTART);
animator.setRepeatCount(ObjectAnimator.INFINITE);
animator.setDuration(5000);
animator.start();
}
@Override
protected void onDraw(Canvas canvas) {
// ......
startAnim();
}
可以看到這樣“小鬼”就已經動起來了,不過現在它肯定沒有那麼萌了。因為它的行進路徑和恐怖片裡那些白衣幽靈看上去一樣一樣的。
不過這裡主要是表達個意思嘛,要實現作者原本的那個動畫效果實際上也不難,我們分析一下可以發現它主要有幾個動作:
就是小鬼在行進的同時還會上下跳動,並且底部的影子會隨著小鬼跳起和落下而改變大小,那麼我們就可以藉助ValueAnimator來實現。
簡單來說,要做的工作就是之前描繪小鬼時的相關屬性(例如小鬼的頭部的圓心座標,影子的rect的寬度等)不要寫死,而是與某個值產生關聯。
然後我們用ValueAnimator來監聽和不斷的改變這個值,然後讓view不斷重繪,就可以得到響應的一些動畫效果了。