【注:】本文首發於簡書,掘金會同步傳送,其餘網站皆無授權。
歡迎瀏覽掘金主頁和簡書主頁,我只是一枚普通的工程師-V-
喜歡自定義控制元件,也喜歡分享我的思路,希望能得到你的批評和建議,也希望能幫到你
衛宮士郎:人被殺,就會死
羽翼君:沒有動畫,View會死
ps:本篇動畫很簡單,請隨意食用,冷藏後風味更佳
Hello,逗比也樂於分享卻常常拖更的羽翼君又回來更新了~
有人反應過我的文章風格不固定,那麼我就先說明一下吧,我的文章,除了不喜歡大段大段的貼程式碼,形式上更像是一個聊天,沒有固定風格,讀的開心就好-V-
當然,作為技術類文章,還是要在可以順著文章讀懂思路的基礎下,亂開槽點的哈哈
Ok,不扯廢話,我們們進入豬蹄主題
在前兩篇,在連我都不知道在扯啥的文章中,我竟然成功的把枯燥的基礎思路與本控制元件核心點給描述出來了,雖然有點模糊抽象,但至少知道,我們做了兩件事:
- 確定了控制元件核心
- 確定了資料承載實體
接下來,我們就基於這兩點開始堆砌我們的裝飾——動畫吧
首先,問一下,啥子是Android的動畫捏(嗯,我不知道左邊這句說的對不對,但覺得挺好聽的。。。ps:我係廣州人喵)
迫不及待的回答:在Android中,動畫可以分為四種,分別是補間動畫、屬性動畫、幀動畫以及之後5.x加入的SVG向量動畫,接下來將對這幾種動畫的使用做一個詳細的介紹:blablabla。。。
羽翼君:來人,拖出去斬了。
小心翼翼的回答:通過插值器、估值器等引數影響動畫線性或非線性行進或時間流逝速度,並根據計算得到的結果作用於目標以達到連續的反饋效果。
羽翼君:來人,唔。。。拖出去彈JJ一百下
滿不在乎的回答:給丫一個東西,給我一個值,剩下的我來讓丫的動起來
羽翼君:來人,收了。
哈哈,首先上面這個小劇場先不提正確與否,但如果要真的去說動畫,我們可需要補充好多知識,比如物理動力學,數學什麼亂七八糟的
但在實際應用中(特指Android動畫),對於動畫,我們先不扯基本的透明/旋轉/位移什麼的,我們用的最多同時也最好理解的,其實就是後面的兩個回答:
我傳入一個值,根據一系列炫酷的操作,在不同的時間算出連續的值,然後我根據這個值做出不同的效果
沒錯,就是這麼粗暴。。。就是這麼不講道理
回到我們專案,如果要做出我們的甜甜圈生長動畫,要怎麼做

非常簡單對吧,幾乎每個寫過自定義圓形進度條的開發者都一定會寫過類似的:
@Override
protected void onDraw(Canvas canvas) {
...前略
/**
* 360乘以百分比得到掃描角度
*/
canvas.drawArc(rectf,0,360 * progress/max,false,paint);
}複製程式碼
不費任何一點精力,done~
然而,真的是這樣嗎?
難點1:分段繪製
1.1:Paint的存放位置
假如真的只有一段,那麼上面的方法就已經很滿足我們的需求了,但是問題來了,我們的甜甜圈,可不僅僅只有一個味道哦,往往食客們要求很多味道混搭,比如上面的動圖,就不止一種味道了。
因此,作為廚師,我們就需要不同的調味料——Paint了。
在製作調味料之前,我們得找個容器放下他們
在上篇,我們已經定義了介面IPieInfo
用於獲取使用者需求,但很顯然,我們的調味料可不能從客戶手中拿到,所以我們不可能在IPieInfo
中約束getPaint()
方法的。
對於使用者的資料,我們用一個List
來儲存,如果我們再弄一個List
來儲存Paint
,顯然是很佔用空間而且維護艱難(比如以後再增加點別的呢。。。事實上,在後面的文章裡,我們還真需要增加別的)
幸運的是,在上一篇文章中,我們就給出了思路:
我們需要一個存放亂七八糟的東西的地方時,就給我創造一個吧
於是,我們就有了PieInfoImpl
類
PieInfoImpl
類是一個final且許可權修飾只是包內引用的類,它相當於一個盒子,裡面除了裝著使用者的資料(IPieInfo),還裝著我們塞進去的各種贈品:角度引數等,該類將會是我們控制元件所用到的核心資料承載體
既然我們都塞了贈品進去了,那麼理所當然的,把我們的調味料也塞進去,因此我們的PieInfoImpl
裡面就可以放我們各種的Paint
了
final class PieInfoImpl {
//上篇文章塞進來的贈品(角度引數)
private final String id;
private final IPieInfo mPieInfo;
private float startAngle;
private float endAngle;
//調味料
private Paint mPaint;
public static PieInfoImpl create(IPieInfo info) {
return new PieInfoImpl(info);
}
private PieInfoImpl(IPieInfo info) {
id = UUID.randomUUID().toString();
this.mPieInfo = info;
initPaint(info);
}
private void initPaint(IPieInfo info) {
if (mPaint == null) mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
if (mLinePath == null) mLinePath = new Path();
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(60);
mPaint.setColor(info.getColor());
}
//其他get/setter忽略
}複製程式碼
【ps:為了直觀描述這次我們加了什麼,特意把上一次定義好的東西也寫了進來,以後為了防止大段的程式碼,將會避免這種貼長篇程式碼的方式,而是隻貼修改的部分。】
在程式碼裡,我們定義了我們的調味料,然後在構造器中初始化我們的畫筆,完事-V-
到目前為止,還是很簡單的嘛~
沒事,我們們由淺入深,三淺一深,方的持久(咳咳,我指文章內容夠長易讀,別想歪了)←_←
1.2:自定義動畫
終於來到動畫了,在一開始的小劇場裡,我們們就說了動畫是啥,那麼在我們的控制元件裡,有什麼是影響到我們每個甜甜圈的大小的呢。
不妨再看看我們的繪製方法(程式碼來源:上一篇文章)
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//...略
canvas.drawArc(mDrawRectf,0,120,false,paint1);
canvas.drawArc(mDrawRectf,120,120,false,paint2);
canvas.drawArc(mDrawRectf,240,120,false,paint3);
}複製程式碼
關於引數,這裡就不詳說了,我們觀察一下這三個曲線有什麼是不同的:
- 起始角度{
0
,120
,240
} - 掃描角度{
120
,120
,120
} - 畫筆{
paint1
,paint2
,paint3
}
別懷疑,掃描角度三個都是120純屬意外哈哈,因為懶,就三個取一樣了。
在深思熟慮(1秒)之後,我們得出結論:起始角度和掃描角度決定了每個甜甜圈的大小。
在上一篇文章裡,我們在新增資料的時候,config的內部類helper就已經給我們算好了起始角度,所以其實起始角度是已經固定了的,我們可以做文章的,就只有掃描角度
所以我們的動畫,是針對掃描角度而定製。
由於我們繼承的是View
,所以這次我們使用Animation而不需要用Animator,畢竟View自帶startAnimation()
方法,有現成的當然用現成的對吧,畢竟我們們懶。。。。
接下來,我們來自定義我們的Animation,目標很簡單:得到一個可變化的角度
首先我們們弄個類,繼承Animation
:
class PieViewAnimation extends Animation {}複製程式碼
然後,我們在方法applyTransformation(float interpolatedTime, Transformation t)
裡面得到動畫計算出來的資料(時間),然後根據這個時間,去計算出我們在每一幀的動畫就可以了
為了方便以後的維護,我們在構造器裡直接把我們的config配置類傳進去
class PieViewAnimation extends Animation {
private AnimatedPieViewConfig mViewConfig;
//構造器略(只是傳入config而已)
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
super.applyTransformation(interpolatedTime, t);
if (mViewConfig == null) {
throw new NoViewConfigException("viewConfig為空");
}
//動畫時間0~1內,算出經過的角度。
if (interpolatedTime >= 0.0f && interpolatedTime <= 1.0f) {
float angle = 360 * interpolatedTime + mViewConfig.getStartAngle();
}
}
}複製程式碼
上面的程式碼很簡單對吧,我們通過動畫時間得到每一幀的角度,得到了角度,我們就可以在View
裡面改變掃描的角度然後不斷的重繪以達到動畫效果。
然而,問題來了:
我們的甜甜圈可不僅僅只有一個,我怎麼知道當前我的角度是屬於哪段甜甜圈的
1.2.1 獲取甜甜圈
在回答問題前,我們不妨想一下,我們目前知道的資訊:
- 每一段甜甜圈的開始/結束角度
- 當前角度
當這兩個條件寫了出來,聰明的你一定知道該怎麼做了吧,那我們就開幹吧
首先,我們在PieInfoImpl
裡面加一個方法:isInAngleRange(float angle)
,這個方法很簡單,就是通過判斷傳入的角度是否在起始和終止角度範圍內返回true or false
然後在動畫執行過程中,我們不斷的遍歷資料來源,拿到當前角度所屬的甜甜圈就可以了。(ps:此處含有優化點),遍歷的程式碼我們放在config的內部類裡面
protected final class AnimatedPieViewHelper {
//其他略
public PieInfoImpl findPieinfoWithAngle(float angle) {
if (ToolUtil.isListEmpty(mDatas)) return null;
for (PieInfoImpl data : mDatas) {
if (data.isInAngleRange(angle)) return data;
}
return null;
}
}複製程式碼
接著在動畫回撥計算中,呼叫即可,同時補上我們的回撥以提供給View
進行操作
PieViewAnimation.java:
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
super.applyTransformation(interpolatedTime, t);
...略
if (interpolatedTime >= 0.0f && interpolatedTime <= 1.0f) {
float angle = 360 * interpolatedTime + mViewConfig.getStartAngle();
//得到甜甜圈
PieInfoImpl info = mViewConfig.getHelper().findPieinfoWithAngle(angle);
if (mHandler != null && info != null) {
mHandler.onAnimationProcessing(angle, info);
}
}
}
//介面的getter/setter略
public interface AnimationHandler {
void onAnimationProcessing(float angle, @NonNull PieInfoImpl infoImpl);
}複製程式碼
題外話:在這裡我提出了一個優化點,是我在寫文章的時候發現的,因為
applyTransformation()
回撥極為頻繁,而我們尋找甜甜圈的方法是遍歷陣列,雖然資料可能不多,但不可否認的是這裡會存在一定的效能問題,所以這裡是一個優化點(考慮索引)
1.3:動畫繪製
動畫有了,資料也有了,剩下來就是奇蹟(坑爹)的時刻——驗證
在控制元件中,我們實現我們剛剛寫好的介面回撥:AnimationHandler
,同時在初始化時把動畫初始化:
AnimatedPieView.java
//其餘成員變數此處略,不想太多程式碼,具體請檢視github原始碼
private void init(){
buildAnima(mConfig);
}
private void buildAnima(AnimatedPieViewConfig config) {
//anim
if (mPieViewAnimation == null) mPieViewAnimation = new PieViewAnimation(config);
mPieViewAnimation.setDuration(config.getDuration());
mPieViewAnimation.setInterpolator(config.getInterpolator());
mPieViewAnimation.bindAnimationHandler(this);
mPieViewAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
isInAnimating = false;
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
}
@Override
public void onAnimationProcessing(float angle, @NonNull PieInfoImpl infoImpl) {
this.angle = angle;
this.mCurrentInfo = infoImpl;
invalidate();
}複製程式碼
上面三個方法只有最後一個需要留意,我們的回撥很簡單,把角度和當前的甜甜圈記錄下來,然後重繪就可以了。。。
於是,我們就高高興興,滿懷期待的寫我們的onDraw()
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
final float width = getWidth() - getPaddingLeft() - getPaddingRight();
final float height = getHeight() - getPaddingTop() - getPaddingBottom();
canvas.translate(width / 2, height / 2);
//半徑
final float radius = Math.min(width, height) / 2 * mConfig.getPieRadiusScale();
mDrawRectf.set(-radius, -radius, radius, radius);
//第三個引數注意角度是相對當前甜甜圈的起始角度,而不是總角度
canvas.drawArc(mDrawRectf, mCurrentInfo.getStartAngle(), angle - mCurrentInfo.getStartAngle(), false, mCurrentInfo.getPaint());
}複製程式碼
“繪製圓弧時第三個角度不可以直接使用動畫計算出的angle,因為動畫計算出的angle是總掃描角度,而不是每一段的掃描角度,因此需要減去當前甜甜圈的起始角度才可以準確表現出每一段的生長”
接著提供一個start()
方法給外部,我們似乎看到距離終點只有一步之遙了:
public void start() {
if (isInAnimating) {
return;
}
isInAnimating = true;
clearAnimation();
startAnimation(mPieViewAnimation);
}複製程式碼
在資料構造完畢,滿懷期待的點下了start
之後,一件意料之外的事情發生了!
"漂亮甜甜圈為何神祕消失,百十行程式碼為何無法達到期望,是程式設計師太笨,還是模擬器太爛?甜甜圈連環失蹤案究竟是何人所為,迷之動畫效果究竟是人是鬼?"
我們是專業的團隊,請跟同《走進科學》的鏡頭一起揭露這。。。。。。哦不對,別打,疼~~ 諸位客官請往下看:

OMG!!!我的甜甜圈,被誰吃了!!!
蛋定蛋定。。。作為一枚專業的程式設計師,我們怎麼可以把鍋推給自己呢,所以我們不妨再看程式碼,
在onDraw()
中,我們做了這麼幾件事:
- 把畫布中心點移到檢視中心
- 根據View大小計算出甜甜圈半徑
- 根據當前角度和當前甜甜圈,繪製圓弧
看起來一切正常,對吧。。。但事實上,我們繪製時呼叫的是invalidate()
,也就是“重繪”,這個方法會請求一次fullInvalidate
,也就是我們上一次繪製的東西是會被清除掉的。
只不過因為繪製太快,所以每一段的動畫在我們眼裡都很流暢。
因此,我們需要針對已經畫完的甜甜圈做一個快取。
1.3.1:繪製快取
當我們繪製完一個甜甜圈,我們需要將這個甜甜圈存下來,然後在下一次繪製的時候把存下來的甜甜圈給完整繪製出來,這樣子達到一個快取效果。
不過有一個問題就是我們的快取時機要確定好,否則的話可能會有意料之外的事情發生。
還是從已知條件出發,我們可以輕易地知道三個角度:起始/終止/當前角度。
因此我們可以得到一個很確鑿的快取時機:當前角度≥當前甜甜圈終止角度時
於是接下來改造我們的甜甜圈程式碼:
AnimatedPieView#onAnimationProcessing()
@Override
public void onAnimationProcessing(float angle, @NonNull PieInfoImpl infoImpl) {
if (mCurrentInfo != null) {
//角度切換時就把畫過的新增到快取,因為角度切換隻有很少的幾次,所以這裡允許迴圈,並不會造成大量的迴圈
if (angle >= mCurrentInfo.getEndAngle()) {
boolean hasAdded = false;
for (PieInfoImpl pieInfo : mDrawedCachePieInfo) {
if (pieInfo.equalsWith(mCurrentInfo)) {
hasAdded = true;
break;
}
}
if (!hasAdded) {
DebugLogUtil.logAngles("超出角度", mCurrentInfo);
mDrawedCachePieInfo.add(mCurrentInfo);
}
}
}
...重繪
}複製程式碼
有一點需要注意的是,我們並不可以判斷角度大於當前甜甜圈的角度,而必須要大於等於,因為當動畫進行到最後,必定會是當前角度=最後一個甜甜圈的結束角度,這樣就會導致無法快取最後畫出來的甜甜圈
然而加上等於判斷後,會存在快取過的甜甜圈被再次新增(當然可以採取set的資料型別,這裡用的list),所以每次新增前都需要一次判斷。
最後補充上我們的onDraw()
快取繪製:
@Override
protected void onDraw(Canvas canvas) {
... 前面保持一樣,略
//繪製快取不空,則繪製
if (!ToolUtil.isListEmpty(mDrawedCachePieInfo)) {
for (PieInfoImpl pieInfo : mDrawedCachePieInfo) {
canvas.drawArc(mDrawRectf, pieInfo.getStartAngle(), pieInfo.getSweepAngle(), !mConfig.isDrawStrokeOnly(), pieInfo.getPaint());
}
}
//第三個引數注意角度是相對當前甜甜圈的起始角度,而不是總角度
canvas.drawArc(mDrawRectf, mCurrentInfo.getStartAngle(), angle - mCurrentInfo.getStartAngle(), false, mCurrentInfo.getPaint());
}複製程式碼
最後,我們就得到了一開始的效果:

嗯。。。本篇簡單的生長的甜甜圈動畫結束。。。
下一篇將會進入點選事件的編寫
前方更加高能。。。我得組織下語言(組織拖更)
【continue】