模仿薄荷健康的滑動卷尺效果。
視覺設計師丟下了一張圖,然后就瀟灑地去喝茶了...
留下孤苦伶仃的你,這個時候旁邊飄來了那英的聲音:你永遠不懂我傷悲,像白天不懂夜的黑...
一、分析
言歸正傳,這個View看起來有兩部分,上面的有兩行字體,下面是一把尺子,從通用性角度來講,上面的兩行字應該由使用者實現(自由度更高),也就是說它不屬于這個View,尺子只需要提供一個回調接口,讓外部拿到它當前的值即可。
雖然這把尺子看起來比較簡單,但是卻蘊含這很多細節,到實現的時候你就會發現了。一般地,拿到一個View的設計稿,我們要去分解里面的元素,然后選擇合適的技術來實現。下面就把這個View搬到解刨臺:
1、背景,可以看到是純色,所以直接畫一個顏色即可,事實上可以支持任意的drawable;
2、刻度,drawLine;
3、刻度下面的數值,drawText
4、三角形指示器,畫幾何圖形(推薦)或者畫一個圖片;
5、滑動的時候不斷的重新繪制;
6、支持快速滑動,涉及到滑動速度的計算。
二、實現
自定義View選擇擴展哪個現成的類有時候是很關鍵的,可能起到事半功倍的效果。遺憾的是,并沒有哪個現成的控件與我們的需求比較相似,所以選擇了擴展View來實現。
TapeView extends View
按照前面分析的步驟一步步來實現吧。
1、畫背景
這個View的背景只是一個簡單的顏色,畫顏色的api有下面幾個
canvas.drawColor(bgColor);
canvas.drawRGB(100, 200, 100);
canvas.drawARGB(100, 100, 200, 100);
2、畫刻度線
刻度線是這個View的核心,也是難點所在,比如說如何保證當前值一定是在View的水平中間位置?這就有點類似于幾何數學中的證明題,證明兩條直線垂直,通常的思考方式是從結果反推。比如這里我們可以先確定View的水平中就是當前值,然后再去反推出其他刻度的位置,這樣就能保證當前值一定是在View的中間(這可是要比證明你爸是你爸要簡單很多)。
知道了當前值就在水平的中間位置,那么是不是就可以反推出來最左邊的第一條刻度線呢?找到第一條刻度線后再順序往右畫出當前可顯示的所有刻度即可。怎么找,請看下面這張很丑的圖:
offset: 最小值到當前值的物理距離(像素)
per: 每一隔代表的數值
gapWidth: 每一隔的物理距離(像素)
minValue: 刻度尺的最小數值
maxValue: 刻度尺的最大數值
middle: View的水平中間位置
從 x=middle-offset+gapWidth*n
這條表達式可以看到,middle、gapWidth都是我們初始化時指定的,改變的只有offset,所以在滑動時我們要隨時改變offset的值,具體來說就是從左往右滑動offset減小,從右往左滑動offset增大,但是要注意offset的有效范圍: 0~(maxValue-minValue)/per*gapWidth。具體實現還是看源碼吧。
是不是特別像小學數學計算距離的應用題?如果你看不懂,那證明我不做老師是對的,不是你的問題。
3、畫三角形
三角形怎么畫?折騰折騰發現canvas有畫矩形、畫圓等api,但是沒有畫三角形的api。這就得借助canvas.drawPath來實現(靈感出自你的知識儲備),控制好三個點的坐標就行。根據視覺圖三角形位置是:頂部,中間。
private void drawTriangle(Canvas canvas) {
paint.setColor(triangleColor);
Path path = new Path();
path.moveTo(getWidth() / 2 - triangleHeight / 2, 0);
path.lineTo(getWidth() / 2, triangleHeight / 2);
path.lineTo(getWidth() / 2 + triangleHeight / 2, 0);
path.close();
canvas.drawPath(path, paint);
}
為什么先畫刻度線而不是先畫三角形?如果是這樣的話,刻度線就會在三角形指示器上面,顏色不一樣就不太美觀了,舉個栗子:
4、滑動處理
滑動處理實際上就是對觸摸事件的處理,根據Android的事件分發機制,當事件傳遞到我們的View時會回調onTouchEvent方法,所以我們可以在這個方法中處理。
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
lastX = x;
dx = 0;
break;
case MotionEvent.ACTION_MOVE:
//dx標識滑動距離,這種計算方式 左-->右 dx小于0,所以重新計算offset時要加上dx
dx = lastX - x;
validateValue();
break;
case MotionEvent.ACTION_UP:
//當滑動結束時如果三角形指示器不是在刻度上,要繼續滑動讓它們對齊
smoothMoveToCalibration();
return false;
default:
return false;
}
lastX = x;
return true;
}
可以看到,滑動處理也很簡單,就是根據滑動距離重新計算offset的值,然后刷新View讓其重新繪制(前面我們說了尺子的狀態由offset決定)。
這里說一點題外話,上面提到當事件傳遞到我們的View時會回調onTouchEvent方法,所以前提是事件要能傳遞到我們的View上,也就是說你不能在父View攔截了事件,或者你給當前View設置了OnTouchListener并且onTouch方法返回了true事件也不會在傳遞到onTouchEvent,為啥子呢,請看下面的代碼:
View#dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent event) {
//省略其他代碼
boolean result = false;
if (onFilterTouchEventForSecurity(event)) {
if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
result = true;
}
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
//onTouch返回ture,resutl就被置為true
result = true;
}
//從這里可以看到onTouch返回true之后,就不會再調用onTouchEvent
if (!result && onTouchEvent(event)) {
result = true;
}
}
...
}
可以看到,事件是先傳遞給了View的OnTouchListener,如果它的onTouch返回true,就不會再調用onTouchEvent。這一部分內容屬于Android的事件分發機制范疇,這里不再深究。
5、彈性滑動
實現彈性滑動的方式有很多,比如通過動畫、Scroller、通過Handler來實現延時策略等,這里采用的是Scroller,當然速度的計算還得借助VelocityTracker速度追蹤器。這里采用Scroller+VelocityTracker來實現。
如何通過VelocityTracker來計算速度?你是否還記得速度的計算公式? V = S/T(物理知識終于派上用場了)
velocityTracker = VelocityTracker.obtain();
velocityTracker.addMovement(event);
//在獲取速度之前一定要先傳入時間(ms)計算一下
velocityTracker.computeCurrentVelocity(1000);
float xVelocity = velocityTracker.getXVelocity();
但是有一點需要注意,Android里的速度計算方式是:速度 = (終點坐標 - 起點坐標)/ 時間。也就是說當你從右往左滑動時,速度是負的,而我們通常理解的速度都是正的。如果你還記的高中物理的動量守恒定律,在矢量方程中符號可以理解為方向,并非只有正負之分。
速度是有了,Scroller是如何實現滑動的?先看看代碼:
private void calculateVelocity() {
velocityTracker.computeCurrentVelocity(1000);
float xVelocity = velocityTracker.getXVelocity(); //計算水平方向的速度
//大于這個值才會被認為是fling
if (Math.abs(xVelocity) > minFlingVelocity) {
scroller.fling(0, 0, (int) xVelocity, 0, Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 0);
//注意這個invalidate
invalidate();
}
}
可以看到,當我們拿到水平方向的速度后,調用了scroller.fling()方法,看著好像是它完成了滑動,其實它內部就是根據參數計算出來一些值并賦值給了它的屬性:
mVelocity = velocity;
mDuration = getSplineFlingDuration(velocity);
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
真正的滑動是在調用invalidate()方法之后,我們知道invalidate()方法會導致View重新繪制,而View的draw方法調用了computeScroll()方法,而computeScroll()方法在View中的默認實現是空的,所以為了完成滑動,我們需要重寫這個方法。
@Override
public void computeScroll() {
super.computeScroll();
//返回true表示滑動還沒有結束
if (scroller.computeScrollOffset()) {
if (scroller.getCurrX() == scroller.getFinalX()) {
smoothMoveToCalibration();
} else {
int x = scroller.getCurrX();
dx = lastX - x;
//繼續讓View刷新
validateValue();
lastX = x;
}
}
}
核心就在 scroller.computeScrollOffset(),它會根據前面調用scroller.fling()計算出來的幾個值來得到View的下一個位置,如此反復到目標位置為止。可以簡單看看computeScrollOffset的FLING_MODE的實現:
/**
* Call this when you want to know the new location. If it returns true,
* the animation is not yet finished.
*/
public boolean computeScrollOffset() {
case FLING_MODE:
//看已過去的時間占總時間的比例
final float t = (float) timePassed / mDuration;
final int index = (int) (NB_SAMPLES * t);
float distanceCoef = 1.f;
float velocityCoef = 0.f;
if (index < NB_SAMPLES) {
final float t_inf = (float) index / NB_SAMPLES;
final float t_sup = (float) (index + 1) / NB_SAMPLES;
final float d_inf = SPLINE_POSITION[index];
final float d_sup = SPLINE_POSITION[index + 1];
velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
distanceCoef = d_inf + (t - t_inf) * velocityCoef;
}
//重新計算了速度
mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;
mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
// Pin to mMinX <= mCurrX <= mMaxX
mCurrX = Math.min(mCurrX, mMaxX);
mCurrX = Math.max(mCurrX, mMinX);//得到了新的滑動位置
}
}
可以看到,它重新計算了mCurrX(就是這次刷新的位置)和 mCurrVelocity(速度),可見這個速度是越來越小,所以我們看到的fling是一種減速運動。
5、還沒完
經過前面的步驟,基本上已經實現了目標效果,來看看效果吧。
但是還有一些細節要做處理,現在拋出這么一個問題,假設用戶在使用你的這個控件時給寬高指定寬高為wrap_conten,你覺得會是怎么樣?答案是和match_parent是一樣的效果。為什么會這樣呢?要解釋這個問題也不難:
ViewGroup#getChildMeasureSpec
// Parent has imposed a maximum size on us
case MeasureSpec.AT_MOST:
if (childDimension >= 0) {
// Child wants a specific size... so be it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size, but our size is not fixed.
// Constrain child to not be bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
看到MATCH_PARENT和WRAP_CONTENT是一樣的了嗎?測量大小都是parentSize,而SpecMode都是AT_MOST。
那怎么處理?可以重寫View的onMeasure()方法,當使用wrap_content時,給它指定一個默認的高度即可。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int mode = MeasureSpec.getMode(heightMeasureSpec);
//當在布局文件設置高度為wrap_content時,默認為80dp(如果不處理效果和math_parent效果一樣),寬度就不處理了
if (mode == MeasureSpec.AT_MOST) {
heightMeasureSpec = MeasureSpec.makeMeasureSpec((int) DisplayUtil.dp2px(80, mContext), MeasureSpec.EXACTLY);
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
這里的處理原則是如果高度是wrap_content就直接指定高度為80dp,寬度不做處理,所以寬度你用wrap_content和match_parent效果都一樣。關于這方面的內容可以研究一下View的測量過程。關于詳細的實現還是看源碼吧。
一般來講,自定義View都需要一些自定義屬性來讓其更加具有通用性,那我們要支持哪些自定義屬性呢?
name | 說明 | format | 默認值 |
---|---|---|---|
bgColor | 背景顏色 | color | #FBE40C |
calibrationColor | 刻度線的顏色 | color | #FFFFFF |
calibrationWidth | 刻度線的寬度 | dimension | 1dp |
calibrationShort | 短的刻度線的長度 | dimension | 20dp |
calibrationLong | 長的刻度線的長度 | dimension | 35dp |
triangleColor | 三角形指示器的顏色 | color | #FFFFFF |
triangleHeight | 三角形的高度 | dimension | 18dp |
textColor | 刻度尺上數值字體顏色 | color | #FFFFFF |
textSize | 刻度尺上數值字體大小 | dimension | 14sp |
per | 兩個刻度之間的代表的數值 | float | 1 |
perCount | 兩條長的刻度線之間的per數量 | integer | 10 |
gapWidth | 刻度之間的物理距離 | dimension | 10dp |
minValue | 刻度尺的最小值 | float | 0 |
maxValue | 刻度之間的最大值 | float | 100 |
value | 當前值 | float | 0 |
什么?這個小東西有這么多屬性?這個問題這樣,如果高度定制,可以寫死一些東西,如果想通用性更好,那就不能寫死一些東西,隨之而來的可能是性能的下降或者復雜度提升。
6、總結
總結這個事,不是每個人都愿意做的?為什么呢,因為不敢。因為走了那么長的路你累了?還是生怕回頭發現你是在耗費青春?亦或是怕回頭看看的時候發現很多東西還沒有搞懂?誰知道呢。無論如何今天要勇敢一把,首先看看前面用到了哪些知識點:
- View的繪制(畫背景、畫刻度線、畫三角形,畫文字)
- View的測量(處理wrap_content)
- 彈性滑動(Scroller)
- 觸摸事件處理(onTouchEvent,提到關于事件傳遞的概念)
這些知識看起來都比較零碎,那如何才能讓自己在自定義View、ViewGroup是沒那么吃力呢,換句話說自定義View應該掌握哪些知識?
- Android系統的事件分發機制,前面也提到了觸摸事件,了解它是如果從屏幕傳遞到目標View,比如處理滑動沖突就要了解事件的分發過程;
- View的measure;
- View的layout;
- View的draw;
- 動畫相關的知識
怎么學?可以結合一些書籍引導著來看源碼,或者直接debug跟蹤一下源碼,通過方法調用棧一步步分析下去。
謝謝大家!
等會兒,視覺設計師喝完茶回來了...