1.寫在文前
按照慣例,反手就是一個超連結: github地址
2.目標
本文要實現的View效果如下圖:
3.分析
從效果圖容易看出,圖中的功能主要分為兩個部分:
- 左側大拇指動畫
- 右側的文字動畫
3.1 左側(PraiseView)
不難發現左側動畫效果主要由三部分組成:
- MotionEvent_DOWN時的拇指縮小,UP時的放大效果
- MotionEvent_UP時的圓圈擴散效果(水波紋效果)
- MotionEvent_UP時的上面的四條線段效果
拇指的縮放各位客觀想必也是心中有數的,無非就是兩種方式:
- 對整個View使用scale動畫
- 對View中的VectorDrawable使用scale動畫 細心的客觀已經發現了當四條線段存在的時候,點選之後,線段也是會隨之縮放的。沒錯,豆豆正是對整個View進行了scale處理。 程式碼如下:
// 處理拇指縮放效果
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
move = event.getY();
animate().scaleY(0.8f).scaleX(0.8f).start();
break;
case MotionEvent.ACTION_UP:
getHandler().postDelayed(new Runnable() {
@Override
public void run() {
animate().cancel();
setScaleX(1);
setScaleY(1);
}
}, 300);
...
// 省略無關程式碼
break;
}
return super.onTouchEvent(event);
}
複製程式碼
#### 3.1.1 圓圈擴散 沒錯,就是畫圈圈。同樣,仔細的同志應該已經發現了些什麼,冥冥之中似乎有些什麼不可告人的祕密。 是的,這裡有兩個需要注意的地方:
- 初始圓圈的半徑,和中心位置,也就是圈圈該畫在哪裡(從圖中不難看出,圓圈是包裹著拇指的)
- measure出View的大小,確認drawable的bound(不自行measure確定view的大小的話,預設的大小是隻會包裹drawable哦~) 廢話不多說,先看程式碼:
// 測量View寬高
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
...
switch (widthSpecMode) {
...
case MeasureSpec.AT_MOST:
widthMeasureSpec = mDrawable.getIntrinsicWidth();
break;
...
}
switch (heightSpecMode) {
...
// wrap_content
case MeasureSpec.AT_MOST:
heightMeasureSpec = mDrawable.getIntrinsicHeight();
break;
...
}
setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
initDrawable(mDrawable, widthMeasureSpec, heightMeasureSpec);
initPointFs(1.3f);
}
// drawable的大小為view的0.6
private void initDrawable(Drawable drawable, int width, int height) {
mCircleCenter.x = width / 2f;
mCircleCenter.y = height / 2;
mDrawable = drawable;
// drawable的邊長為view的0.6
float diameter = (float) ((width > height ? height : width) * 0.6);
int left = (int) ((width - diameter)/2);
int top = (int)(height - diameter)/2;
int right = (int) (left + diameter);
int bottom = (int) (top + diameter);
Rect drawableRect = new Rect(left, top, right, bottom);
mDrawable.setBounds(drawableRect);
requestLayout();
}
複製程式碼
由此計算出了view和drawable的大小,從而可以去畫他了。這樣我們就確認了圈圈該畫在哪裡,接下來的擴散效果,只需要控制圈圈的半徑即可,依舊看程式碼:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mDrawable.draw(canvas);
drawEffect(canvas);
}
private void drawEffect(Canvas canvas) {
// 畫圓
if (mRadius > 0)
canvas.drawCircle(mCircleCenter.x, mCircleCenter.y, mRadius, mPaint);
if (drawLines == 1) {
// 劃線
...
}
public void animation() {
final float radius = getInitRadius(mDrawable);
ObjectAnimator animator = ObjectAnimator.ofFloat(this, "radius", radius, radius * 1.5f, radius * 3.0f);
animator.setInterpolator(new AnticipateInterpolator());
animator.setDuration(500);
// 畫線
// ...
set.start();
}
複製程式碼
至此我們完成了拇指的縮放和波紋效果,心裡美滋滋有木有 #### 3.1.2 線段效果 線段怎麼去畫呢?中小學老師告訴我們,兩點確認一條線段。問題隨之轉換:
- 那麼我們如何確認這兩點的位置呢?
- 為了可持續發展,我們該怎麼樣去確定兩條線段直接的距離呢? 各位客官,不妨喝杯茶,吃點瓜子,思考下上面這個問題。 ... ... // 優雅的喝茶timing ... 細心的朋友已經注意到我之前的onMeasure方法中有一個initPointFs(1.3);沒錯,就是在獲取View的大小後,進行了對點的計算,看程式碼:
/**
* 用於計算 線條的長度
* @param scale 外圓半徑為內圓半徑的scale倍數
*/
private void initPointFs(float scale) {
mPointList.clear();
float radius = getInitRadius(mDrawable);
int base = -60;
int factor = -20;
for (int i = 0; i < 4; i++) {
int result = base + factor * i;
// 點p1為mDrawable外接圓上的點
PointF p1 = new PointF(
mCircleCenter.x + (float) (radius * Math.cos(Math.toRadians(result))),
mCircleCenter.y + (float) (radius * Math.sin(Math.toRadians(result)))
);
// 點p1為mDrawable外接圓scale倍上的點
PointF p2 = new PointF(
mCircleCenter.x + (float) (scale * radius * Math.cos(Math.toRadians(result))),
mCircleCenter.y + (float) (scale * radius * Math.sin(Math.toRadians(result)))
);
mPointList.add(p1);
mPointList.add(p2);
}
}
複製程式碼
通過程式碼註解不難發現,這裡我們巧妙的利用同心圓和角度的方式來確定了4條線段,8個點集合的值(豆豆不禁感嘆,數學對程式設計師的重要性)。這樣做的好處就是足夠靈活,無論View大小如何變,線段的間隔和長短都是適宜的。 至此左側的拇指動畫效果,算是告一段落了。
3.2 右側(RecordView)
右邊的數字翻牌效果,乍看起來很簡單,無非就是drawText()累加之後重新drawText();原理上是這樣的沒錯,不過值得注意的是:
- 無需變化的數位上的值不會被翻動
- 上下翻動時前一個數字會漸漸隱掉 先看,測量過程: 從圖中我們不難發現,測量的高度值應當為Text的3倍,用於顯示前一個,當前,和下一個的數字值 寬度可以直接從api中獲取當前的text的寬即可,看程式碼:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
···
switch (widthSpecMode) {
···
case MeasureSpec.AT_MOST:
int width = (int) mPaint.measureText("0", 0, 1) * mCurrentString.length();
widthMeasureSpec = width;
break;
···
}
switch (heightSpecMode) {
···
case MeasureSpec.AT_MOST:
mTextHeight = mPaint.getFontSpacing();
heightMeasureSpec = (int) (mTextHeight * 3);
break;
case MeasureSpec.EXACTLY:
mPaint.setTextSize(heightSpecSize / 4);
mTextHeight = (int) mPaint.getFontSpacing();
heightMeasureSpec = heightSpecSize;
break;
}
pointY = 2 * mTextHeight;
setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
}
複製程式碼
在測量出View的寬高之後,便要著手去畫view的內容了,而內容很簡單,就是一系列的String值。到這裡都比較容易實現,而難點則是,確定上一個和下一個值,以及他們的位置。 細心的朋友可能已經發現在measure的時候,我們有一個mTextHeigh記錄了文字的高度,pointY記錄了兩倍文字的高度,沒錯這裡就是利用mTextHeight來控制三個可能要畫出來的string值的位置的。
這裡有必要提一下的是,drawText(@NonNull String text, float x, float y, @NonNull Paint paint)這個方法中的float y對應的是baseLine的y值,簡單的理解的話就是一串String的bottom的位置,畫出來的內容是在bottom之上的。這也是為什麼我們要用pointY = 2 * mTextHeight的理由。至此不難想到,我們的lastNum, currentNum, NextNum畫的位置,分別對應mTextHeight, 2 * mTextHeight和3 * mTextHeight。至此三個值的位置便算是確定好了。
3.2.1 加1動畫
先看加1的處理,上程式碼:
public void addOne() {
mCurrentString = String.valueOf(mCurrentNum);
mCurrentNum++;
mNextString = String.valueOf(mCurrentNum);
mStatus = ADD;
// 數字位數進1
if (mCurrentString.length() < mNextString.length()) {
mCurrentString = " " + mCurrentString;
requestLayout();
}
ObjectAnimator animator = ObjectAnimator.ofFloat(this, "pointY", 2 * mTextHeight, mTextHeight);
ObjectAnimator alphaAnim = ObjectAnimator.ofInt(this, "paintAlpha", 255, 0);
AnimatorSet set = new AnimatorSet();
set.playTogether(alphaAnim, animator);
set.start();
}
複製程式碼
程式碼比較簡單,無非是做了移動和透明度的動畫效果,這裡便解決了“上下翻動時前一個數字會漸漸隱掉”的需求,需要注意的點是,數字位進1時的利用空格佔位的處理,不做該處理,當數字進位後,動畫效果會差強人意,有興趣的朋友可以去試試看。 結合onDraw方法再來看看:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mStatus == NONE) {
canvas.drawText(mCurrentString, 0, pointY, mPaint);
} else if (mStatus == ADD) {
for (int i = mNextString.length() - 1; i >= 0; i--) {
String next = String.valueOf(mNextString.charAt(i));
String current = String.valueOf(mCurrentString.charAt(i));
// i位置需要改變
if (!next.equals(current)) {
mPaint.setAlpha(mPaintAlpha);
canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, pointY, mPaint);
// mPaintAlpha : 255 - 0 遞減
mPaint.setAlpha(255 - mPaintAlpha);
canvas.drawText(next, mPaint.measureText("0", 0, 1) * i, mTextHeight + pointY, mPaint);
// i位置不需要改變
} else {
mPaint.setAlpha(255);
canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, mTextHeight * 2, mPaint);
}
}
} else if (mStatus == REDUCE) {
// pointY是累加的,因此有個往下滑動效果
for (int i = mCurrentString.length() - 1; i >= 0; i--) {
String last = String.valueOf(mLastString.charAt(i));
String current = String.valueOf(mCurrentString.charAt(i));
// i位置需要改變
if (!last.equals(current)) {
mPaint.setAlpha(mPaintAlpha);
canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, mTextHeight + pointY, mPaint);
// mPaintAlpha : 255 - 0 遞減
mPaint.setAlpha(255 - mPaintAlpha);
canvas.drawText(last, mPaint.measureText("0", 0, 1) * i, pointY, mPaint);
// i位置不需要改變
} else {
mPaint.setAlpha(255);
canvas.drawText(current, mPaint.measureText("0", 0, 1) * i, mTextHeight * 2, mPaint);
}
}
}
}
複製程式碼
這裡便是核心所在了:如何無需變化的數位上的值不會被翻動? onDraw方法中給出了我們答案,思路很簡單:
- 將接下來要顯示的數字和當前的正在顯示的數字的每一位數一一對比如果不同,則通過動畫效果重畫,相同,則不走動畫效果,直接畫出來即可。
至此gif圖中的兩部分效果都已經實現
3.3 整體(PraiseRecordView)
以上是分開獨立的兩個view,為了更方便的使用這個效果,我們需要將兩個view的功能整合在一起,起到一個聯動效果,也就需要引入一個ViewGroup去確定這兩個view(PraiseView和RecordView)的佈局,這部分主要涉及到layout,以及viewgroup測繪的時候,使用的是match_parent寬高時,如何控制子view的顯示,有興趣的朋友可以直接去看程式碼,這裡暫不做贅述了。
4 總結
行文至此,我不禁點了根黃鶴樓,望著那嫋嫋的煙,一抬手摸著了天... 天邊飄來一個: github地址
5 福利
附贈優惠禮包自取: 阿里雲飛機票