尺子從一,分為四的故事(BooheeRuler的創造和重構思路)

Yanzhikai發表於2017-12-07

尺子從一,分為四的故事(BooheeRuler的創造和重構思路)

本文出處: 炎之鎧csdn部落格:http://blog.csdn.net/totond 炎之鎧郵箱:yanzhikai_yjk@qq.com 本專案Github地址:https://github.com/totond/BooheeRuler 本文原創,轉載請註明本出處!

尺子從一,分為四的故事(BooheeRuler的創造和重構思路)

前言

  整整一個月沒寫部落格了,因為最近工作非常忙和週末不加班的話有一些其他的事(其實就是懶了),這篇文章其實很早就想寫了,不過我看到網路上很多類似的尺子分析文章,我發現它們都說了大部分了,所以就沒好意思重複寫了,最新因為BooheeRuler經歷大幅度重構,所以我才想著把這些經驗記錄分享一下。

  本文主要是講述BooheeRuler從只能橫向使用發展到擁有四個形態的過程,如上圖。

  BooheeRuler是在扔物線凱哥的一個自定義View仿寫活動裡面仿寫薄荷健康APP的裡面的一個尺子,結束之後,沒想到兄弟們這麼熱情,在issue上提建議和發郵件和我交流,非常感謝,而也是因為有不少兄弟發郵件讓我做縱向的尺子,我才能在這個專案非常忙的十一月把它做出來。在現在這個專案休整期,我在這裡記錄分享一下。

本文章主要是分享BooheeRuler重構思路和一些實現細節,如果有不瞭解這個控制元件的可以先去Github上的README掃兩眼,相信會對理解下面的內容更有幫助^_^。

更新過程

  活動結束之後,BooheeRuler經歷了多次的更新,把這些更新列出來,才好具體說明我做了什麼。下面是直到現在的更新List:

  • 2017/10/23 version 0.0.5:

    • 修改了畫刻度的方法,改為只畫當前螢幕顯示的刻度
    • 增加了count屬性,用於設定一個大刻度格子裡面的小刻度格子數,預設是10
  • 2017/10/30 version 0.0.6:

    • 加入VelocityTracker的回收處理(之前只是clear並沒有recycle),提高效能。
    • 加入屬性paddingStartAndEnd,用於控制尺子兩端的padding。
    • 讓刻度的繪製前後多半大格,這樣可以提前顯示出下面的數字,讓過渡不會變得那麼突兀。
    • 取消了一些不必要的log輸出。
  • 2017/10/30 version 0.0.7:

    • 之前VelocityTracker重複使用了addMovement,現在取消掉了。
  • 2017/11/15 version 0.1.0:

    • 重構程式碼,將尺子分為4個形態。
    • 對細節有一些小改動:如背景設定換成以InnerRuler為主體,優化Padding等。
  • 2017/11/16 version 0.1.1:

    • 修復KgNumberLayout修改單位會出錯的bug
  • 2017/11/28 version 0.1.2:

    • 修復觸發ACTION_CANCEL事件會令刻度停在非整點地方的bug
    • 增加邊緣效果
  • 2017/12/13 version 0.1.3:

    • 效能優化:BooheeRuler的onLayout之前沒有利用change屬性,導致每次重新整理都會重新Layout,現在不會。
    • 效能優化:修改了畫刻度的邏輯,現在在刻度範圍很大的情況下還可以順暢滑動。
    • 修復了goToScale()重複回撥導致顯示刻度不準確的問題。

實現思路

特點

  BooheeRuler具有以下的一些特點:

  • 觸控滑動後是有慣性滾動的。
  • 游標保持在中間,不隨尺子滾動。
  • 游標選中的刻度只會是0.1整點,所以觸控滑動、慣性滾動完了之後,尺子會回滾到最近的整點刻度。
  • 滑動到邊緣的時候有邊緣的效果提示。
  • 可以有4種樣式的尺子選擇。

涉及知識點

  BooheeRuler涉及但不止於以下的一些知識點:

  • 讓控制元件滑動的知識,如scrollTo()的使用,OverScroller的使用等。
  • 觸控控制的知識,如重寫onTouchEvent()實現觸控控制滑動。
  • 自定義View的封裝知識,如自定義屬性的設定與使用等。
  • 自定義ViewGroup的簡單使用,如BooheeRuler包裹InnerRuler、實現padding等。
  • 自定義View的繪畫知識,如刻度的繪畫。
  • 邊緣效果的實現。
  • 設計模式的使用,如通過策略模式來將尺子分成4種形態。

結構

  一開始是隻有頭向上的尺子的(只有BooheeRuler加InnerRuler,如果想了解具體的話可以參考2017/10/30前的commit),為了實現4種尺子,首先就是構建抽象類,把4種尺子共同的邏輯抽象出來,下面是描述了部分屬性和方法的UML類圖 :

尺子從一,分為四的故事(BooheeRuler的創造和重構思路)

  這樣能大大減少重複的程式碼,缺點就是邏輯分開了,想通過程式碼看實現一個尺子的邏輯就要跳來跳去有點不方便。在一開始給出這個結構圖,是為了給出一個目錄,讓我們在後面提到跳轉的時候知道目的地。   下面我將會以頭向上的TopHeadRuler為例,將尺子的實現特點一個一個地講述出來,可能有些特點會跨越幾個類。如果是是一個一個類的介紹,我覺得這樣思路反而更加不清晰。

其實這個重構思路和我之前寫的YMenu重構過程有點類似,這個是應用了模板方法模式的重構,有興趣的可以看下,打個廣告^_^。

封裝

  從上面的結構可以看到BooheeRuler實際是一個外殼,其實它一個ViewGroup,把InnerRuler包裹住,這樣做的原因主要是因為InnerRuler的滑動是使用scrollTo()方法(scrollBy()方法其實最後也是呼叫scrollTo()方法而已),這樣會導致整個畫面移動。而因為中間的選定游標是一個Drawable(為了靈活性),Drawable改變自己的位置的方法setBound()比較耗時,會導致尺子滑動比較卡,所以不能採用讓游標隨著畫面滑動而自己也改變繪畫位置來保持在中間的方法。最後還是保持了這個使用外殼的方法。   然後,這個外殼也理所當然充當了尺子和外部交流的中介了,使用者決定的屬性也是傳入到這裡,而InnerRuler裡面則是用mParent.getXXX()的方法獲取這些屬性。   使用者通過設定rulerStyle來選擇尺子的形態:

//這裡是BooheeRuler

    private void initRuler(Context context) {
        mContext = context;
        switch (mStyle){
            case TOP_HEAD:
                mInnerRuler = new TopHeadRuler(context, this);
                paddingHorizontal();
                break;
            case BOTTOM_HEAD:
                mInnerRuler = new BottomHeadRuler(context, this);
                paddingHorizontal();
                break;
            case LEFT_HEAD:
                mInnerRuler = new LeftHeadRuler(context, this);
                paddingVertical();
                break;
            case RIGHT_HEAD:
                mInnerRuler = new RightHeadRuler(context, this);
                paddingVertical();
                break;
        }

        //設定全屏,加入InnerRuler
        LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        mInnerRuler.setLayoutParams(layoutParams);
        addView(mInnerRuler);
        //設定ViewGroup可畫
        setWillNotDraw(false);

        initPaint();
        initDrawable();
        initRulerBackground();
    }
複製程式碼

  這裡就是策略模式的實現,讓使用者選擇不同的策略來決定使用不同形態的尺子。

刻度的繪製

  從最簡單的刻度繪製入手,這裡以頭向上的TopHeadRuler為例:

    //畫刻度和字
    private void drawScale(Canvas canvas) {
        //計算開始和結束刻畫時候的刻度
        float start = (getScrollX() - mDrawOffset) / mParent.getInterval() + mParent.getMinScale();
        float end = (getScrollX() + canvas.getWidth() + mDrawOffset) / mParent.getInterval() + mParent.getMinScale();
        for (float i = start; i <= end; i++) {
            //將要刻畫的刻度轉化為位置資訊
            float locationX = (i - mParent.getMinScale()) * mParent.getInterval();

            if (i >= mParent.getMinScale() && i <= mParent.getMaxScale()) {
                if (i % mCount == 0) {
                    canvas.drawLine(locationX, 0, locationX, mParent.getBigScaleLength(), mBigScalePaint);
                    canvas.drawText(String.valueOf(i / 10), locationX, mParent.getTextMarginHead(), mTextPaint);
                } else {
                    canvas.drawLine(locationX, 0, locationX, mParent.getSmallScaleLength(), mSmallScalePaint);
                }
            }
        }
        //畫輪廓線
        canvas.drawLine(getScrollX(), 0, getScrollX() + canvas.getWidth(), 0, mOutLinePaint);

    }

複製程式碼

  上面涉及到的一些在BooheeRuler的屬性介紹如下:

屬性名稱 意義 型別 預設值
minScale 尺子的最小刻度值 integer 464(在尺子上顯示就是46.4)
maxScale 尺子的最大刻度值 integer 2000(在尺子上顯示就是200.0)
smallScaleLength 尺子小刻度(0.1)的刻度線長度 dimension 30px
smallScaleWidth 尺子小刻度(0.1)的刻度線寬度/粗細 dimension 3px
bigScaleLength 尺子大刻度(1.0)的刻度線長度 dimension 60px
bigScaleWidth 尺子大刻度(1.0)的刻度線寬度/粗細 dimension 5px
scaleInterval 尺子每條刻度線之間的距離 dimension 18px
textMarginHead 尺子數字文字距離邊界距離 dimension 120px

  到這裡就比較清晰了,主要思路就是:

  • 讓i從最小刻度到最大刻度遍歷,然後把i轉化為對應的滑動偏移量locationX(相當於i這個刻度對應所在的scrollX)。
  • 如果locationX是處在可繪畫範圍內,也就是if (locationX > getScrollX() - mDrawOffset && locationX < (getScrollX() + canvas.getWidth() + mDrawOffset)) 為true的時候,就開始畫刻度啦。

這裡非常感謝littlezan提出的效能優化的建議。0.1.3版本後,BooheeRuler通過動態改變刻度線刻畫的迴圈條件來減少迴圈次數,大大提高了刻度範圍較大的時候的尺子效能。

  • 先將可繪畫範圍轉為為刻度值start和end。
  • 然後遍歷裡面的整數,讓裡面的刻度轉化為位置資訊,來繪製刻度。
  • 這個可繪畫範圍就是尺子的當前螢幕顯示範圍加上兩邊的mDrawOffset距離,這個mDrawOffset值是半個大格子刻度的長度,這樣做是因為有時候尺子邊緣滑到一個快到整數點的時候如70.9,按照現實,下面的數字71應該會顯示出一小半的身形了,但是如果不加mDrawOffset這個提前刻畫量的話,這裡是不會顯示出71了,而是讓邊緣滑到71.0的時候再一整個冒出來,非常突兀,所以這裡就加上了mDrawOffset屬性讓尺子預繪畫半大格(version 0.0.6更新)。
    尺子從一,分為四的故事(BooheeRuler的創造和重構思路)

觸控控制與滑動

  在4種尺子中,這部分的邏輯是有共通點的,所以可以把它們分為橫向和縱向的兩類,這裡以TopHeadRuler實現橫向的滑動為例,主要是它的父類HorizontalRuler實現。   在HorizontalRuler重寫onTouchEvent()方法,來處理觸控事件:

觸控控制

  實現觸控控制是很簡單的,只需要記錄手指滑動的橫向距離,讓尺子滑動相同的橫向距離就行了:

//這裡是HorizontalRuler

    //處理滑動,主要是觸控的時候通過計算現在的event座標和上一個的位移量來決定scrollBy()的多少
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float currentX = event.getX();
        //...

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //...

                mLastX = currentX;
                break;
            case MotionEvent.ACTION_MOVE:
                float moveX = mLastX - currentX;
                mLastX = currentX;
                scrollBy((int) (moveX), 0);
                break;
            //...
        }
        return true;
    }
複製程式碼

  通過這些程式碼,就能實現觸控控制,但是控制之後的慣性滑動才是難點:

慣性滑動

  首先這裡重寫了scrollTo()方法:

//這裡是HorizontalRuler

    //重寫滑動方法,設定到邊界的時候不滑,並顯示邊緣效果。滑動完輸出刻度。
    @Override
    public void scrollTo(@Px int x, @Px int y) {
        if (x < mMinPosition) {
            goStartEdgeEffect(x);
            x = mMinPosition;
        }
        if (x > mMaxPosition) {
            goEndEdgeEffect(x);
            x = mMaxPosition;
        }
        if (x != getScrollX()) {
            super.scrollTo(x, y);
        }

        //輸出刻度
        mCurrentScale = scrollXtoScale(x);
        if (mRulerCallback != null) {
            mRulerCallback.onScaleChanging(Math.round(mCurrentScale));
        }
    }
複製程式碼

  在手指滑動的時候計算速度,然後手指離開的時候按照這個速度來計算後面的慣性滑動:

//這裡是HorizontalRuler

    //處理滑動,主要是觸控的時候通過計算現在的event座標和上一個的位移量來決定scrollBy()的多少
    //滑動完之後計算速度是否滿足Fling,滿足則使用OverScroller來計算Fling滑動
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float currentX = event.getX();
        //開始速度檢測
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mOverScroller.isFinished()) {
                    mOverScroller.abortAnimation();
                }

                mLastX = currentX;
                break;
            case MotionEvent.ACTION_MOVE:
                float moveX = mLastX - currentX;
                mLastX = currentX;
                scrollBy((int) (moveX), 0);
                break;
            case MotionEvent.ACTION_UP:
                //處理鬆手後的Fling
                mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                int velocityX = (int) mVelocityTracker.getXVelocity();
                if (Math.abs(velocityX) > mMinimumVelocity) {
                    fling(-velocityX);
                } else {
                    scrollBackToCurrentScale();
                }
                //VelocityTracker回收
                if (mVelocityTracker != null) {
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                }
                releaseEdgeEffects();
                break;
            case MotionEvent.ACTION_CANCEL:
                if (!mOverScroller.isFinished()) {
                    mOverScroller.abortAnimation();
                }
                //回滾到整點刻度
                scrollBackToCurrentScale();
                //VelocityTracker回收
                if (mVelocityTracker != null) {
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                }
                releaseEdgeEffects();
                break;
        }
        return true;
    }

    private void fling(int vX) {
        mOverScroller.fling(getScrollX(), 0, vX, 0, mMinPosition - mEdgeLength, mMaxPosition + mEdgeLength, 0, 0);
        invalidate();
    }

複製程式碼

  慣性滑動使用的是OverScroller,從上面邏輯可以看出,當手指鬆開時(ACTION_UP),將會檢測當前滑動速度是否大於mMinimumVelocity,大於的話就會使用mOverScroller來進行fling慣性滑動,否則就是回滾到當前的整點刻度scrollBackToCurrentScale()

慣性滑動結束的判斷

  要使用OverScroller,就要重寫computeScroll()方法,由於這裡的橫向縱向滑動邏輯都是一樣,都是滑完之後回滾到最近的刻度點,所以computeScroll()方法就放到父類InnerRuler裡面寫了:

//這裡是InnerRuler

    @Override
    public void computeScroll() {
        if (mOverScroller.computeScrollOffset()) {
            scrollTo(mOverScroller.getCurrX(), mOverScroller.getCurrY());

            //這是最後OverScroller的最後一次滑動,如果這次滑動完了mCurrentScale不是整數,則把尺子移動到最近的整數位置
            if (!mOverScroller.computeScrollOffset() && mCurrentScale != Math.round(mCurrentScale)){
                //Fling完進行一次檢測回滾
                scrollBackToCurrentScale();
            }
            invalidate();
        }
    }

    protected abstract void scrollBackToCurrentScale();
複製程式碼

  這裡的判斷條件if (!mOverScroller.computeScrollOffset() && mCurrentScale != Math.round(mCurrentScale))是一種比較巧妙的方法,以後下面會提到scrollBackToCurrentScale()方法也是使用裡慣性滑動的方式來讓尺子回彈,所以如果不加上檢測mCurrentScale是否已經為整數的話,這個方法會在慣性滑動的時候進入死迴圈(慣性滑動結束後又執行慣性滑動),加入了這個判斷就可以尺子只回滾一次。

滑動結束的回滾

  當尺子停止滑動時,需要回滾到最近的刻度點,就需要用到下面的方法:

//這裡是HorizontalRuler

    //把滑動偏移量scrollX轉化為刻度Scale
    private float scrollXtoScale(int scrollX) {
        return ((float) (scrollX - mMinPosition) / mLength) * mMaxLength + mParent.getMinScale();
    }

    //把Scale轉化為ScrollX
    private int scaleToScrollX(float scale) {
        return (int) ((scale - mParent.getMinScale()) / mMaxLength * mLength + mMinPosition);
    }


    //把移動後游標對準距離最近的刻度,就是回彈到最近刻度
    @Override
    protected void scrollBackToCurrentScale() {
        //漸變回彈
        mOverScroller.startScroll(getScrollX(), 0, scaleToScrollX(Math.round(mCurrentScale)) - getScrollX(), 0, 500);
        invalidate();

        //立刻回彈
//        scrollTo(scaleToScrollX(mCurrentScale),0);
    }
複製程式碼

  還記得前面重寫scrollTo()方法的最後,就把當前的scrollX值轉化為mCurrentScale了,因為每一個滑動操作都會呼叫scrollTo()方法,所以在scrollBackToCurrentScale()這裡就可以直接將mCurrentScale的四捨五入值轉化為目標scrollX,讓尺子在500ms之內回滾過去了。

邊緣效果實現

  這是0.1.2版本新增的效果,在API大於等於21(Android5.0)的時候是這樣的:

尺子從一,分為四的故事(BooheeRuler的創造和重構思路)
  在API小於21(Android5.0)的時候是這樣的:
尺子從一,分為四的故事(BooheeRuler的創造和重構思路)

  實現這個效果是用了EdgeEffect類,其實谷歌給了它的封裝類EffectCompat來讓我們使用,但是無奈的是我沒有在這個類的原始碼裡面找出設定顏色的API(這是谷歌考慮到API版本適配的問題還是我眼花?)所以就只好直接用EdgeEffect類了。   因為所有尺子都是有兩個邊緣(start和end),所以初始化操作寫在InnerRuler裡面:

//這裡是InnerRuler

    //初始化邊緣效果
    public void initEdgeEffects(){
        if (mParent.canEdgeEffect()) {
            if (mStartEdgeEffect == null || mEndEdgeEffect == null) {
                mStartEdgeEffect = new EdgeEffect(mContext);
                mEndEdgeEffect = new EdgeEffect(mContext);
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                    mStartEdgeEffect.setColor(mParent.getEdgeColor());
                    mEndEdgeEffect.setColor(mParent.getEdgeColor());
                }
                mEdgeLength = mParent.getCursorHeight() + mParent.getInterval() * mParent.getCount();
            }
        }
    }
複製程式碼

  當滑動到邊緣時,再滑動(也就相當於scroll值超出滑動範圍),就會觸發邊緣效果:

//這裡是HorizontalRuler

    //重寫滑動方法,設定到邊界的時候不滑,並顯示邊緣效果。滑動完輸出刻度。
    @Override
    public void scrollTo(@Px int x, @Px int y) {
        if (x < mMinPosition) {
            goStartEdgeEffect(x);
            x = mMinPosition;
        }
        if (x > mMaxPosition) {
            goEndEdgeEffect(x);
            x = mMaxPosition;
        }
        if (x != getScrollX()) {
            super.scrollTo(x, y);
        }

        mCurrentScale = scrollXtoScale(x);
        if (mRulerCallback != null) {
            mRulerCallback.onScaleChanging(Math.round(mCurrentScale));
        }

    }
複製程式碼

  上面再次列出重寫的scrollTo()方法,然後下面就是觸發邊緣效果的程式碼:

//這裡是TopHeadRuler

    //頭部邊緣效果處理
    private void goStartEdgeEffect(int x){
        if (mParent.canEdgeEffect()) {
            if (!mOverScroller.isFinished()) {
                mStartEdgeEffect.onAbsorb((int) mOverScroller.getCurrVelocity());
                mOverScroller.abortAnimation();
            } else {
                mStartEdgeEffect.onPull((float) (mMinPosition - x) / (mEdgeLength) * 3 + 0.3f);
                mStartEdgeEffect.setSize(mParent.getCursorHeight(), getWidth());
            }
            postInvalidateOnAnimation();
        }
    }

    //尾部邊緣效果處理
    private void goEndEdgeEffect(int x){
        if(mParent.canEdgeEffect()) {
            if (!mOverScroller.isFinished()) {
                mEndEdgeEffect.onAbsorb((int) mOverScroller.getCurrVelocity());
                mOverScroller.abortAnimation();
            } else {
                mEndEdgeEffect.onPull((float) (x - mMaxPosition) / (mEdgeLength) * 3 + 0.3f);
                mEndEdgeEffect.setSize(mParent.getCursorHeight(), getWidth());
            }
            postInvalidateOnAnimation();
        }
    }

    //取消邊緣效果動畫
    private void releaseEdgeEffects(){
        if (mParent.canEdgeEffect()) {
            mStartEdgeEffect.onRelease();
            mEndEdgeEffect.onRelease();
        }
    }
複製程式碼

  EdgeEffect的onPull()就是讓手指拖動過界會觸發邊緣效果,而onAbsorb()方法就是讓慣性滑動觸發邊緣效果。最後就是要在4種尺子裡面各自的onDraw()方法裡面畫效果了:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        drawScale(canvas);
        drawEdgeEffect(canvas);
    }

    //畫邊緣效果
    private void drawEdgeEffect(Canvas canvas) {
        if (mParent.canEdgeEffect()) {
            if (!mStartEdgeEffect.isFinished()) {
                int count = canvas.save();
                //旋轉位移Canvas來使EdgeEffect繪畫在正確的地方
                canvas.rotate(270);
                canvas.translate(-mParent.getCursorHeight(), 0);
                if (mStartEdgeEffect.draw(canvas)) {
                    postInvalidateOnAnimation();
                }
                canvas.restoreToCount(count);
            } else {
                mStartEdgeEffect.finish();
            }
            if (!mEndEdgeEffect.isFinished()) {
                int count = canvas.save();
                canvas.rotate(90);
                canvas.translate(0, -mLength);
                if (mEndEdgeEffect.draw(canvas)) {
                    postInvalidateOnAnimation();
                }
                canvas.restoreToCount(count);
            } else {
                mEndEdgeEffect.finish();
            }
        }
    }
複製程式碼

  畫EdgeEffect是一件很有dan趣teng的事情,因為EdgeEffect固定是要畫在Canvas的頭部,所以 要通過旋轉平移來調整它的位置,而旋轉的原點是在Canvas的零點,整個座標軸還是要跟著旋轉的,一開始的時候我沒意識到這兩點導致調了好久。

總結

  上面就是實現BooheeRuler和重構的思路了,只是大概地說了一下流程,瞭解了這個流程,就應該大概知道BooheeRuler的運作方式了,還有一些比較細的地方(如Padding、回撥之類的)沒有具體描述,如果有興趣的話可以到Github上瞭解下原始碼。   這篇文章主要介紹了BooheeRuler經歷重構之後的實現思路,相比之前一整個在一塊的時候,思路比較分散了吧,但是也很輕鬆地複用了相同的邏輯,比重新寫3種尺子快多了。

後話

  不斷地學習和總結能讓自己的技術得到穩定的提升,在這裡非常感謝為我提Bug和提需求的兄弟們,給了我前進的動力。另外小弟不才,如果文章有什麼說錯或者對BooheeRuler有什麼意見和建議,歡迎評論或者在Github上提出issue,請多多指教。   最後,讀到這裡,就是希望喜歡的話大哥大姐們能賞個Star啦^_^!

相關文章