1.寫在文前
按照慣例,反手就是一個(gè)超鏈接:
github地址
2.目標(biāo)
本文要實(shí)現(xiàn)的View效果如下圖:

3.分析
從效果圖容易看出,圖中的功能主要分為兩個(gè)部分:
- 左側(cè)大拇指動(dòng)畫
- 右側(cè)的文字動(dòng)畫
3.1 左側(cè)(PraiseView)
不難發(fā)現(xiàn)左側(cè)動(dòng)畫效果主要由三部分組成:
- MotionEvent_DOWN時(shí)的拇指縮小,UP時(shí)的放大效果
- MotionEvent_UP時(shí)的圓圈擴(kuò)散效果(水波紋效果)
- MotionEvent_UP時(shí)的上面的四條線段效果
拇指的縮放各位客觀想必也是心中有數(shù)的,無非就是兩種方式:
- 對(duì)整個(gè)View使用scale動(dòng)畫
- 對(duì)View中的VectorDrawable使用scale動(dòng)畫
細(xì)心的客觀已經(jīng)發(fā)現(xiàn)了當(dāng)四條線段存在的時(shí)候,點(diǎn)擊之后,線段也是會(huì)隨之縮放的。沒錯(cuò),豆豆正是對(duì)整個(gè)View進(jìn)行了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);
...
// 省略無關(guān)代碼
break;
}
return super.onTouchEvent(event);
}
3.1.1 圓圈擴(kuò)散
沒錯(cuò),就是畫圈圈。同樣,仔細(xì)的同志應(yīng)該已經(jīng)發(fā)現(xiàn)了些什么,冥冥之中似乎有些什么不可告人的秘密。
是的,這里有兩個(gè)需要注意的地方:
- 初始圓圈的半徑,和中心位置,也就是圈圈該畫在哪里(從圖中不難看出,圓圈是包裹著拇指的)
- measure出View的大小,確認(rèn)drawable的bound(不自行measure確定view的大小的話,默認(rèn)的大小是只會(huì)包裹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();
}
由此計(jì)算出了view和drawable的大小,從而可以去畫他了。這樣我們就確認(rèn)了圈圈該畫在哪里,接下來的擴(kuò)散效果,只需要控制圈圈的半徑即可,依舊看代碼:
@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 線段效果
線段怎么去畫呢?中小學(xué)老師告訴我們,兩點(diǎn)確認(rèn)一條線段。問題隨之轉(zhuǎn)換:
- 那么我們?nèi)绾未_認(rèn)這兩點(diǎn)的位置呢?
- 為了可持續(xù)發(fā)展,我們?cè)撛趺礃尤ゴ_定兩條線段直接的距離呢?
各位客官,不妨喝杯茶,吃點(diǎn)瓜子,思考下上面這個(gè)問題。
...
... // 優(yōu)雅的喝茶timing
...
細(xì)心的朋友已經(jīng)注意到我之前的onMeasure方法中有一個(gè)initPointFs(1.3);沒錯(cuò),就是在獲取View的大小后,進(jìn)行了對(duì)點(diǎn)的計(jì)算,看代碼:
/**
* 用于計(jì)算 線條的長度
* @param scale 外圓半徑為內(nèi)圓半徑的scale倍數(shù)
*/
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;
// 點(diǎn)p1為mDrawable外接圓上的點(diǎn)
PointF p1 = new PointF(
mCircleCenter.x + (float) (radius * Math.cos(Math.toRadians(result))),
mCircleCenter.y + (float) (radius * Math.sin(Math.toRadians(result)))
);
// 點(diǎn)p1為mDrawable外接圓scale倍上的點(diǎn)
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);
}
}
通過代碼注解不難發(fā)現(xiàn),這里我們巧妙的利用同心圓和角度的方式來確定了4條線段,8個(gè)點(diǎn)集合的值(豆豆不禁感嘆,數(shù)學(xué)對(duì)程序員的重要性)。這樣做的好處就是足夠靈活,無論View大小如何變,線段的間隔和長短都是適宜的。
至此左側(cè)的拇指動(dòng)畫效果,算是告一段落了。
3.2 右側(cè)(RecordView)
右邊的數(shù)字翻牌效果,乍看起來很簡單,無非就是drawText()累加之后重新drawText();原理上是這樣的沒錯(cuò),不過值得注意的是:
- 無需變化的數(shù)位上的值不會(huì)被翻動(dòng)
- 上下翻動(dòng)時(shí)前一個(gè)數(shù)字會(huì)漸漸隱掉
先看,測量過程:
從圖中我們不難發(fā)現(xiàn),測量的高度值應(yīng)當(dāng)為Text的3倍,用于顯示前一個(gè),當(dāng)前,和下一個(gè)的數(shù)字值
寬度可以直接從api中獲取當(dāng)前的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的內(nèi)容了,而內(nèi)容很簡單,就是一系列的String值。到這里都比較容易實(shí)現(xiàn),而難點(diǎn)則是,確定上一個(gè)和下一個(gè)值,以及他們的位置。
細(xì)心的朋友可能已經(jīng)發(fā)現(xiàn)在measure的時(shí)候,我們有一個(gè)mTextHeigh記錄了文字的高度,pointY記錄了兩倍文字的高度,沒錯(cuò)這里就是利用mTextHeight來控制三個(gè)可能要畫出來的string值的位置的。
這里有必要提一下的是,drawText(@NonNull String text, float x, float y, @NonNull Paint paint)這個(gè)方法中的float y對(duì)應(yīng)的是baseLine的y值,簡單的理解的話就是一串String的bottom的位置,畫出來的內(nèi)容是在bottom之上的。這也是為什么我們要用pointY = 2 * mTextHeight的理由。至此不難想到,我們的lastNum, currentNum, NextNum畫的位置,分別對(duì)應(yīng)mTextHeight, 2 * mTextHeight和3 * mTextHeight。至此三個(gè)值的位置便算是確定好了。
3.2.1 加1動(dòng)畫
先看加1的處理,上代碼:
public void addOne() {
mCurrentString = String.valueOf(mCurrentNum);
mCurrentNum++;
mNextString = String.valueOf(mCurrentNum);
mStatus = ADD;
// 數(shù)字位數(shù)進(jìn)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();
}
代碼比較簡單,無非是做了移動(dòng)和透明度的動(dòng)畫效果,這里便解決了“上下翻動(dòng)時(shí)前一個(gè)數(shù)字會(huì)漸漸隱掉”的需求,需要注意的點(diǎn)是,數(shù)字位進(jìn)1時(shí)的利用空格占位的處理,不做該處理,當(dāng)數(shù)字進(jìn)位后,動(dòng)畫效果會(huì)差強(qiáng)人意,有興趣的朋友可以去試試看。
結(jié)合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是累加的,因此有個(gè)往下滑動(dòng)效果
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);
}
}
}
}
這里便是核心所在了:如何無需變化的數(shù)位上的值不會(huì)被翻動(dòng)?
onDraw方法中給出了我們答案,思路很簡單:
- 將接下來要顯示的數(shù)字和當(dāng)前的正在顯示的數(shù)字的每一位數(shù)一一對(duì)比如果不同,則通過動(dòng)畫效果重畫,相同,則不走動(dòng)畫效果,直接畫出來即可。
至此gif圖中的兩部分效果都已經(jīng)實(shí)現(xiàn)
3.3 整體(PraiseRecordView)
以上是分開獨(dú)立的兩個(gè)view,為了更方便的使用這個(gè)效果,我們需要將兩個(gè)view的功能整合在一起,起到一個(gè)聯(lián)動(dòng)效果,也就需要引入一個(gè)ViewGroup去確定這兩個(gè)view(PraiseView和RecordView)的布局,這部分主要涉及到layout,以及viewgroup測繪的時(shí)候,使用的是match_parent寬高時(shí),如何控制子view的顯示,有興趣的朋友可以直接去看代碼,這里暫不做贅述了。同時(shí)
4 總結(jié)
行文至此,我不禁點(diǎn)了根黃鶴樓,望著那裊裊的煙,一抬手摸著了天...
天邊飄來一個(gè):
github地址