帶你實現漂亮的滑動卷尺

模仿薄荷健康的滑動卷尺效果。

薄荷健康滑動卷

視覺設計師丟下了一張圖,然后就瀟灑地去喝茶了...

留下孤苦伶仃的你,這個時候旁邊飄來了那英的聲音:你永遠不懂我傷悲,像白天不懂夜的黑...

一、分析

言歸正傳,這個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、總結

總結這個事,不是每個人都愿意做的?為什么呢,因為不敢。因為走了那么長的路你累了?還是生怕回頭發現你是在耗費青春?亦或是怕回頭看看的時候發現很多東西還沒有搞懂?誰知道呢。無論如何今天要勇敢一把,首先看看前面用到了哪些知識點:

  1. View的繪制(畫背景、畫刻度線、畫三角形,畫文字)
  2. View的測量(處理wrap_content)
  3. 彈性滑動(Scroller)
  4. 觸摸事件處理(onTouchEvent,提到關于事件傳遞的概念)

這些知識看起來都比較零碎,那如何才能讓自己在自定義View、ViewGroup是沒那么吃力呢,換句話說自定義View應該掌握哪些知識?

  1. Android系統的事件分發機制,前面也提到了觸摸事件,了解它是如果從屏幕傳遞到目標View,比如處理滑動沖突就要了解事件的分發過程;
  2. View的measure
  3. View的layout
  4. View的draw;
  5. 動畫相關的知識

怎么學?可以結合一些書籍引導著來看源碼,或者直接debug跟蹤一下源碼,通過方法調用棧一步步分析下去。

謝謝大家!

等會兒,視覺設計師喝完茶回來了...

源碼傳送門

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容