Android自定義View教你一步一步實現薄荷健康滑動捲尺

真丶深紅騎士發表於2019-01-02

前言

前幾天寫了一篇一步一步教你實現即刻點贊效果後,實現點贊效果主要是自己對自定義View的一些canvas繪製,縮放知識,位移的理解。而朋友說HenCoder還有給出薄荷健康滑動捲尺,小米運動記錄介面,Flipboard 紅板報的翻頁效果。這幾個例子對自定義View知識很有代表性,都用到了不同的知識。而今天要實現的是薄荷健康滑動捲尺效果,主要是加深觸控反饋,和在Android座標系中,獲取View不同環境下座標系的方法,也剛好鞏固滑動如scrllTo()和scrllBy()用法。

效果圖

效果圖
仔細觀察上面的效果圖,有六個點是可以觀察知道的:

  1. 刻度尺是可以左右滑動的,看到實際的刻度尺是比所看到的的區域要長的。
  2. 刻度尺的刻度線有長有短,刻度線之間的間隔都是固定1,從1開始每隔10刻度線變長。
  3. 圖中有一條綠線,這條綠線比刻度線都要長和粗。
  4. 當刻度尺停下來時,綠線所指的刻度就是綠色文字所顯示的數字。
  5. 文字顯示的數值是有單位的:Kg在數字的右上角,並伴隨著刻度變化而左右一點距離。
  6. 具有慣性滑動。

知識準備

scrllTo和scrollBy

剛看到上圖,就馬上想到了Android裡的Scroller,這個類是專門處理滾動的工具類,我們平時在開發中直接使用Scroller的場景不多,但是我們很多時候都會接觸到它,像ViewPager、ListView。在Android中任何一個控制元件都是可以移動的,因為VIew類中有scrollTo()和scrollBy()方法。

scrllTo

scrllTo官方文件
意思是設定View的滾動位置,這會導致onScrollChanged(int,int,int,int)的呼叫,並且會重新整理View。

scrollBy

scrllBy
意思是移動檢視的滾動位置,這將導致對onScrollChanged(int,int,int,int)的呼叫,並且會重新整理View。 這麼說還是不明白,舉個例子。 activity_main佈局檔案:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout android:id="@+id/activity_main"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="com.uestc.horizontalrulerview.MainActivity">


    <Button
        android:id="@+id/btn_one"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="BUTTONONE"/>

    <Button
        android:id="@+id/btn_two"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/btn_one"
        android:text="BUTTONTWO"/>



</RelativeLayout>
複製程式碼

MainActivity檔案:

public class MainActivity extends AppCompatActivity {


    private Button btn_one;
    private Button btn_two;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        btn_one = (Button) findViewById(R.id.btn_one);
        btn_two = (Button) findViewById(R.id.btn_two);

        btn_one.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                btn_one.scrollTo(-50, -100);
            }
        });

        btn_two.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                btn_two.scrollBy(-50, -100);
            }
        });
    }

}
複製程式碼

效果如下:

button移動
發現button沒有移動,但是裡面的文字缺不見了,可以猜測,應該是移動自己佈局裡面的內容。現在在佈局檔案給兩個button加上一個父佈局:

<RelativeLayout android:id="@+id/activity_main"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="com.uestc.horizontalrulerview.MainActivity">

   <LinearLayout
       android:id="@+id/ll_btn"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:orientation="vertical">
    <Button
        android:id="@+id/btn_one"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="BUTTONONE"/>

    <Button
        android:id="@+id/btn_two"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="BUTTONTWO"/>

   </LinearLayout>

</RelativeLayout>
複製程式碼

MainActivity檔案改為:

       btn_one.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ll_btn.scrollTo(-50,-100);
            }
        });

        btn_two.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ll_btn.scrollBy(-50,-100);
            }
        });
複製程式碼

改為對這個佈局進行滾動。 看看效果:

佈局移動
BUTTONONE呼叫了scrollTo方法,BUTTONTWO呼叫了scrollBy方法,發現BUTTONONE呼叫了一次scrollTo方法後繼續呼叫會沒有效果,而BUTTONTWO呼叫了scrollBy方法後繼續呼叫還會繼續滾動。那麼下面可以得出結論:

  • scrollTo和scrollBy只是移動自己的內容.也就是如果ViewGroup設定scrollTo或者scrollBy的話,只有它的子View會有位移效果.如果是TextView設定scrollTo或者scrollBy的話只會讓它內部的文字發生位移.
  • scrollBy()方法是讓View相對於當前的位置滾動某段距離,而scrollTo()方法則是讓View相對於初始的位置滾動某段距離。 這裡也許大家也像我一樣會有疑問:為什麼設定了**scrollTo(-50,-100)scrollBy(-50,-100)**卻往右下移動呢?按照正常來講,Android中的座標系原點是在螢幕的左上角,x軸向右是正值,反之是負值,y軸向下是正值,向上是負值,所以應該往左上移動。想找出答案還是看原始碼,因為仔細分析原始碼比較長,上面也寫了移動是會View會重新整理,那麼最後肯定會執行invaildate方法,這裡另外說一下另外一個檢視重新整理方法postInvalidate,我們知道Android是不能在子執行緒中更新UI的,這個方法可以直接在子執行緒中更新檢視,通過ViewRootImpl這個頂級檢視檢查管理類去負責分發輪詢處理,然後在主執行緒呼叫invalidate方法,實現檢視控制元件的執行緒安全,換句話說postInvalidate最後還是呼叫invalidate方法。 invalidate最後通過ViewRootImpl類重寫的invalidateChild方法對子View進行繪製,這就可以解釋為什麼scrollTo的作用在View的內容上了,最後再執行下面這個方法:
    public void scrollTo(int x, int y) {
        if (mScrollX != x || mScrollY != y) {
            int oldX = mScrollX;
            int oldY = mScrollY;
            mScrollX = x;
            mScrollY = y;
            invalidateParentCaches();
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);
            if (!awakenScrollBars()) {
                postInvalidateOnAnimation();
            }
        }
    }
複製程式碼
    public void invalidate(int l, int t, int r, int b) {
        final int scrollX = mScrollX;
        final int scrollY = mScrollY;
        invalidateInternal(l - scrollX, t - scrollY, r - scrollX, b - scrollY, true, false);
    }
複製程式碼

這裡就可以知道看到的矩形是l-scrollX,t-scrollY,r-scrollX,b-scrollY,這就是為什麼scrollTo設定負值就是往正方向走,設定負值往反方向走,並且裡面加了判斷條件if(mScrollx != x || mScrollY != y),第一次呼叫這個方法時,x的值賦給了mScrollX,y的值賦給了mScrollY,而再後面呼叫這個方法因為x等於mScrollX,y等於mScrollY,因此不會執行進入條件內的程式碼。原始碼中scrollBy還是呼叫了scrollTo方法:

    public void scrollBy(int x, int y) {
        scrollTo(mScrollX + x, mScrollY + y);
    }
複製程式碼

引數是(mScrollX + x,mScrollY + y),這裡就可以解釋,scrollTo方法只會讓View移動一次,它是對View初始方向來說,而scrollBy是對View的現在位置來說,所以可以不斷移動。

簡單繪製文字

佈局檔案:

public class SlidingRuleView extends View {

    //文字畫筆
    private Paint paint;
    //文字足夠長 超過螢幕顯示寬度 方便後面看滑動效果
    private String currentNum = "1234sdddddddddd423dddddddd234dddddd234dddddd23423dddddddd234ddddd234ddddddd23423dddddd23ddd234ddddddd34334ddddddddddddddddddddddddddddddddsdddddddddddd";
    //這個自定義View的高度
    private int height;
    public SlidingRuleView(Context context) {
        this(context,null);

    }

    public SlidingRuleView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs,0);
    }

    public SlidingRuleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }


    private void init(Context context){
        //初始化畫筆 抗鋸齒
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        //畫筆的顏色 黑色
        paint.setColor(Color.BLACK);
        //設定填充樣式,只繪製圖形的輪廓
        paint.setStyle(Paint.Style.STROKE);
        //設定文字大小
        paint.setTextSize(25f);
    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //MeasureSpec值由specMode和specSize共同組成,onMeasure兩個引數的作用根據specMode的不同,有所區別。
        //當specMode為EXACTLY時,子檢視的大小會根據specSize的大小來設定,對於佈局引數中的match_parent或者精確大小值
        //當specMode為AT_MOST時,這兩個引數只表示了子檢視當前可以使用的最大空間大小,而子檢視的實際大小不一定是specSize。所以我們自定義View時,重寫onMeasure方法主要是在AT_MOST模式時,為子檢視設定一個預設的大小,對於佈局引數wrap_content。
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        if (heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSpecSize, SystemUtil.dp2px(getContext(),60));
        } else {
            setMeasuredDimension(widthSpecSize, heightSpecSize);
        }
        //這裡獲取View的高度 方便後面繪製算一些座標
        height = getMeasuredHeight();
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //得到文字的字型屬性和測量
        Paint.FontMetrics fontMetrics = paint.getFontMetrics();
        //文字設定在View的中間
        float y = height / 2 + (Math.abs(fontMetrics.ascent) - fontMetrics.descent) / 2;
        //canvas繪製文字
        canvas.drawText(currentNum, 0,y, paint);
    }
}
複製程式碼

下面重點講解onMeasure方法和繪製文字方法

 		int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        //MeasureSpec值由specMode和specSize共同組成,onMeasure兩個引數的作用根據specMode的不同,有所區別。
        //當specMode為EXACTLY時,子檢視的大小會根據specSize的大小來設定,對於佈局引數中的match_parent或者精確大小值
        //當specMode為AT_MOST時,這兩個引數只表示了子檢視當前可以使用的最大空間大小,而子檢視的實際大小不一定是specSize。所以我們自定義View時,重寫onMeasure方法主要是在AT_MOST模式時,為子檢視設定一個預設的大小,對於佈局引數wrap_content。
        if (heightSpecMode == MeasureSpec.AT_MOST) {
            //這個方法確定了當前View的大小
            setMeasuredDimension(widthSpecSize, SystemUtil.dp2px(getContext(),60));
        } else {
            setMeasuredDimension(widthSpecSize, heightSpecSize);
        }
複製程式碼

這裡是獲取specMode的模式和specSize大小,為什麼確定View的大小根據heightSpecMode呢,因為要實現的滑動捲尺只是橫向滑動,width設定精準值、wrap_content和match_parent都是可以的,不需要處理,超過View顯示的區域到時候可以通過滑動來顯示。實現這個效果高度一般設定wrap_content,當設定wrap_content時,最好設定一個固定高度,上面程式碼設定60px,如果不進行處理的話。有可能佔滿父容器所給的高度,或者高度過小顯示不全。這裡稍微講下Measure.Mode測量模式:

  1. UNSPECIFIED 父容器不對子View做任何限制,要多大給多大,一般用於系統內部,這裡就不用多考慮

  2. EXACTLY 精準模式,一般View指定了具體的大小(dp/px)或者設定match_parent

  3. AT_MOST 父容器制定了一個可用的大小,子View不能大於這個值,這個是在佈局設定wrap_content

最後還發現呼叫了setMeasuredDimension,這個方法主要是決定當前View的大小,onMeasure方法最後呼叫setMeasuredDimension方法儲存測量的寬高值,當然寫在onMesure方法裡,也說明它會呼叫多次,因為有的時候,一次測量,當父控制元件發現子控制元件的尺寸不符合要求就會重新測量。如果不呼叫這個方法,可能會產生不可預測的問題。 下面講下定位文字座標的方法:

        //得到文字的字型屬性和測量
        Paint.FontMetrics fontMetrics = paint.getFontMetrics();
        //文字設定在View的中間
        float y = height / 2 + (Math.abs(fontMetrics.ascent) + fontMetrics.descent) / 2;
        //canvas繪製文字
        canvas.drawText(currentNum, 0,y, paint);
複製程式碼

x座標就不講了,這裡重點講一下y座標,這裡主要用到了FontMetrics這個類,官網解釋是:

FontMetrics
這裡結合一張圖來講:
TextView示意圖
圖中有五條線結合官方文件,自上而下來解釋:

  • top:給定文字大小下,字型中最高字元高於基線之上的最大距離
  • ascent:單個文字下超出基線之上的推薦距離
  • baseLine:文字基線
  • descent:單個字元超出基線之上的推薦距離
  • bottom:字型中最低字元超出基線之下的最大距離
  • leading:文字行與文字行之間的距離

上面圖中紅色的圓點,那個點對於TextView來說就是基線的原點,現在問題是要確定這個點對於這個View下的y座標,可以看到這個點離整個View的中線下移一段距離,這段距離我是設定整個文字高度的一半,文字字型的高度可以用Math.abs(ascent) + descent,那麼文字高度的一半也就是**(Math.abs(ascent) + descent)/ 2**。因此最終紅色原點對於整個View的y座標是float y = height / 2 + (Math.abs(fontMetrics.ascent) + fontMetrics.descent) / 2;

具體實踐

完成滑動

上面講了些基礎知識,下面講述最核心的就是滑動效果,在init方法裡建立滾動例項:

        //建立滑動例項
        mScroller = new Scroller(context);
複製程式碼

因為滑動只是View裡面的TextView,要確定最大的左右滑動邊界值,這裡先上一個圖,就是Android中View的座標和獲取一些距離方法:

Android中一些座標獲取
ViewGroup就是平時一些LinearLayout,RelativeLayout佈局,View如TextView,ImageView這些控制元件。 View提供的獲取座標以及距離的方法:

  • getTop獲取的是View自身頂邊到父佈局頂邊的距離
  • getLeft獲取的是View自身左邊到父佈局左邊的距離
  • getRight獲取的是View自身右邊到父佈局左邊的距離
  • getBottom獲取的是View自身底邊到父佈局頂邊的距離

MotionEvent提供的方法:

  • getX獲取觸控事件觸控點距離控制元件左邊的距離,是檢視座標
  • getY獲取觸控事件觸控點距離控制元件頂邊的距離,是檢視座標
  • getRawX獲取觸控事件觸控點距離整個螢幕左邊的距離,是絕對座標
  • getRawY獲取觸控事件觸控點距離整個螢幕頂邊的距離,是絕對座標

在ondraw方法分別得到View自身左邊距離父佈局左邊距離和View自身右邊到父佈局左邊的距離:

        //得到左右邊界
        leftBorder = getLeft();
        rightBorder = (int)paint.measureText(currentNum);
複製程式碼

currentNum就是TextView顯示的文字內容,Paint.measureText就是測量文字的寬度。每個View都有onTouchEvent方法,onTouchEvent有手指觸控螢幕MotionEvent.ACTION_DOEM,MotionEvent.Action_MOVE方法,那就在這個方法實現滑動邏輯。

 @Override
    public boolean onTouchEvent(MotionEvent ev){
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                //記錄初始觸控螢幕下的座標
                mXDown = ev.getRawX();
                mLastMoveX = mXDown;
                break;
            case MotionEvent.ACTION_MOVE:
                mCurrentMoveX = ev.getRawX();
                //本次的滑動距離
                int scrolledX = (int) (mLastMoveX - mCurrentMoveX);
                //如果右滑時 內容左邊界超過初始化時候的左邊界 就還是初始化時候的狀態
                if(getScrollX() + scrolledX < leftBorder){
                    scrollTo(leftBorder,0);
                }
                //同理 如果左滑  這裡判斷右邊界
                else if(getScrollX() + getWidth() + scrolledX > rightBorder){
                    scrollTo(rightBorder - getWidth(),0);
                }else{

                    //在左右邊界中 自由滑動
                    scrollBy(scrolledX,0);
                }
                mLastMoveX = mCurrentMoveX;
                break;
        }
        return true;
    }
複製程式碼

上面程式碼主要最難理解的就是邊界檢測,下面是左邊界檢測程式碼:

                //如果右滑時 內容左邊界超過初始化時候的左邊界 就還是初始化時候的狀態
                if(getScrollX() + scrolledX < leftBorder){
                    scrollTo(leftBorder,0);
                }
複製程式碼

這裡用了getScrollX方法,這個方法是返回當前View檢視左上角X座標與View檢視初始位置左上角X座標的距離,注意,這是以螢幕座標為參照點,View右移這個值由正變為負數一直遞增。 這個其實列出一張圖理解:

邊界檢測示意圖
結合上面程式碼來看,一開始判斷右滑到達左邊界的時候,是通過滑動後TextView的左邊界在初始狀態時的左邊界右邊時,就是右滑達到最大值。因為這裡getScrollx取得值和我們正常理解的值是反的:

if(getScrollX()  < leftBorder){
                    scrollTo(leftBorder,0);
                }
複製程式碼

這樣來判斷,這裡預設leftBorder是0,也就是父佈局的左邊界和內容左邊界一致重疊。但我發現效果會有抖動,應該是臨界值沒有判斷到位,然後加上移動距離。

getScrollX() + scrolledX < leftBorder
複製程式碼

其實轉換為自然語言就是,View的移動距離比當前View檢視左上角座標與View檢視初始位置x軸方向上的距離大,同理左滑時邊界檢測也是一樣。注意:在非左右邊界情況下,要用scrollBy方法來移動,因為這個是對於當前View位置來說的,還有,onTouchEvent要返回return true。因為return false或者return super.onTouchEvent只會執行down方法,不會執行move和up方法,只有在true的時候,三個都會執行,具體什麼原因自行查詢事件分發和消耗。其實這裡不用重寫computerScroll方法,就是在其內部完成平滑移動,computeScroll在父控制元件執行drawChild時,會呼叫這個方法。,效果圖如下:

滑動效果圖

繪製頂部刻度長線

在attrs下新增屬性集合:

    <declare-styleable name="SlidingRuleView">
         <!--長刻度的長度-->
        <attr name="longDegreeLine" format="dimension"/>
        <!--//線條顏色-->
        <attr name="lineDegreeColor" format="color" />
        <!--頂部的直線距離View頂部距離-->
        <attr name="topDegreeLine" format="dimension"/>
        <!-- 刻度間隔-->
        <attr name="lineDegreeSpace" format="dimension"/>
        <!-- 刻度大數目 -->
        <attr name="lineCount" format="integer"/>
        
    </declare-styleable>
複製程式碼

在建構函式讀取attrs檔案下屬性:

public SlidingRuleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //初始化一些引數
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable
                .SlidingRuleView);

        //刻度線的顏色
        lineDegreeColor = typedArray.getColor(R.styleable.SlidingRuleView_lineDegreeColor, Color.LTGRAY);
        //頂部的直線距離View頂部距離
        topDegreeLine = typedArray.getDimension(R.styleable.SlidingRuleView_topDegreeLine, SystemUtil.dp2px(getContext(),45));
        //刻度間隔
        lineDegreeSpace = typedArray.getDimension(R.styleable.SlidingRuleView_lineDegreeSpace, SystemUtil.dp2px(getContext(),10));
        //刻度大數目 預設30
        lineCount = typedArray.getInt(R.styleable.SlidingRuleView_lineCount, 30);
        init(context);
        typedArray.recycle();
    }
複製程式碼

初始化方法init確定頂部刻度線的右端:

    private void init(Context context){
        //初始化畫筆 抗鋸齒
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        //建立滑動例項
        mScroller = new Scroller(context);
        //第一步,獲取Android常量距離物件,這個類有UI中所使用到的標準常量,像超時,尺寸,距離
        ViewConfiguration configuration = ViewConfiguration.get(context);
        //獲取最小移動距離
        mTouchMinDistance = configuration.getScaledTouchSlop();
        //確定刻頂部度長線右邊界 格數 * 之間的間隔 * 大數目(間隔)之間是有10小間隔的
        rightBorder = lineDegreeSpace * lineCount * 10;
    }
複製程式碼

這裡解釋一下**rightBorder = lineDegreeSpace * lineCount * 10;**意思是刻度之間的間隔 * 大刻度數 * 每個大刻度之間會有10個小刻度。 ondraw方法繪製:

 @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //確定頂部長線的左端
        float x = leftBorder;
        //確定頂部長線
        float y = topDegreeLine;
        //設定畫筆顏色
        paint.setColor(lineDegreeColor);
        //設定刻度線寬度
        paint.setStrokeWidth(3);
        canvas.drawLine(x, y, rightBorder, y, paint);

    }
複製程式碼

這樣頂部刻度長線繪製完成:

頂部刻度長線

繪製長刻度

在構造方法增加:

        //長的刻度線條長度
        longDegreeLine = typedArray.getDimension(R.styleable.SlidingRuleView_longDegreeLine, SystemUtil.dp2px(getContext(),35));
複製程式碼

在onDraw方法裡新增:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //確定頂部長線的左端
        float x = leftBorder;
        //確定頂部長線
        float y = topDegreeLine;
        //設定畫筆顏色
        paint.setColor(lineDegreeColor);
        //設定刻度線寬度
        paint.setStrokeWidth(3);
        canvas.drawLine(x, y, rightBorder, y, paint);
        //迴圈繪製
        for(int i = 0;i <= lineCount * 10;i++){
            //畫長刻度
            if(i % 10 == 0){
                paint.setColor(lineDegreeColor);
                paint.setStrokeWidth(5);
                canvas.drawLine(x, y, x, y + longDegreeLine, paint);
            }
            x += lineDegreeSpace;
        }

    }
複製程式碼

迴圈繪製裡,我這邊是迴圈所有的刻度值,但是現在只繪製長刻度,因此i % 10 == 0的時候才繪製,因為繪製是從左往右的,每個刻度值的間隔是用lineDegreeSpace表示,因此每迴圈一遍,X座標的值要對應增加x += lineDegreeSpace。 執行效果如下:

繪製長刻度線
發現左右邊界的刻度線太靠邊了,加上左右間隔:

        <!--刻度尺左邊界記錄View左邊界的距離-->
        <attr name="ruleLeftSpacing" format="dimension" />
        <!--刻度尺右邊界記錄View右邊界的距離-->
        <attr name="ruleRightSpacing" format="dimension" />
複製程式碼

構造方法增加:

        ruleLeftSpacing = typedArray.getDimension(R.styleable.SlidingRuleView_ruleLeftSpacing, SystemUtil.dp2px(getContext(),5));
        ruleRightSpacing = typedArray.getDimension(R.styleable.SlidingRuleView_ruleRightSpacing, SystemUtil.dp2px(getContext(),5));
複製程式碼

初始化init方法變成:

        //增加左邊界距離
        leftBorder = ruleLeftSpacing;
        //確定刻頂部度長線右邊界 格數 * 之間的間隔 * 大數目(間隔)之間是有10小間隔的
        rightBorder = lineDegreeSpace * lineCount * 10+ ruleLeftSpacing + ruleRightSpacing;
複製程式碼

ondraw繪製頂部長線變為:

        canvas.drawLine(x, y, rightBorder - ruleRightSpacing, y, paint);
複製程式碼

onTouchEvent方法左右檢測需要加上左右邊距

    @Override
    public boolean onTouchEvent(MotionEvent ev){
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                //記錄初始觸控螢幕下的座標
                mXDown = ev.getRawX();
                mLastMoveX = mXDown;
                break;
            case MotionEvent.ACTION_MOVE:
                mCurrentMoveX = ev.getRawX();
                //本次的滑動距離
                int scrolledX = (int) (mLastMoveX - mCurrentMoveX);
                //如果右滑時 內容左邊界超過初始化時候的左邊界 就還是初始化時候的狀態
                if(getScrollX() + scrolledX < leftBorder){
                    scrollTo((int)(-leftBorder),0);
                    return true;
                }
                //同理 如果左滑  這裡判斷右邊界
                else if(getScrollX() + getWidth() + scrolledX > rightBorder){
                    scrollTo((int)(rightBorder - getWidth() + ruleRightSpacing),0);
                    return true;
                }else{

                    //左右邊界中 自由滑動
                    scrollBy(scrolledX,0);
                }
                //當停止滑動時,現在的滑動已經變成上次滑動
                mLastMoveX = mCurrentMoveX;
                break;
        }
        return true;
    }
複製程式碼

執行效果如下:

邊界加間隔後的效果

繪製長刻度值

在attrs檔案下新增數字的顏色和大小:

        <!--//數字顏色-->
        <attr name="numberColor" format="color" />
        <!--//數字大小-->
        <attr name="numberSize" format="dimension" />
複製程式碼

在構造方法增加對attrrs屬性獲取:

        //數字顏色
        numberColor = typedArray.getColor(R.styleable.SlidingRuleView_numberColor, Color.BLACK);
        //數字大小
        numberSize = typedArray.getDimension(R.styleable.SlidingRuleView_numberSize, SystemUtil.dp2px(getContext(),15));
複製程式碼

onDraw方法繪製數字:

                //畫刻度值
                String number = String.valueOf(i / 10);
                //得到文字寬度
                float textWidth = paint.measureText(number);
                //繪製顏色
                paint.setColor(numberColor);
                //繪製文字大小
                paint.setTextSize(numberSize);
                paint.setStrokeWidth(1);
                canvas.drawText(number, x - textWidth / 2, y + longDegreeLine + SystemUtil.dp2px(getContext(),25), paint);
複製程式碼

這裡主要講下,數字的座標,因為數字是在刻度線正下方的,所以x座標應該是刻度線的x座標減去本身自身寬度的一半,y座標應該是刻度線長度再加上部分距離,我這邊是加了25dp。 執行效果:

加刻度線效果

繪製短刻度值

在attrs檔案增加短刻度的長度:

        <!--短刻度值的長度-->
        <attr name="shortDegreeLine" format="dimension"/>
複製程式碼

在建構函式獲取其屬性:

        //短刻度值的長度
        shortDegreeLine = typedArray.getDimension(R.styleable.SlidingRuleView_shortDegreeLine, SystemUtil.dp2px(getContext(),20));
複製程式碼

在onDraw方法迴圈裡非i%10==0的情況下繪製,這裡很好理解:

 //迴圈繪製
        for(int i = 0;i <= lineCount * 10;i++){
            //畫長刻度
            if(i % 10 == 0){
                paint.setColor(lineDegreeColor);
                paint.setStrokeWidth(5);
                canvas.drawLine(x, y, x, y + longDegreeLine, paint);

                //畫刻度值
                String number = String.valueOf(i / 10);
                //得到文字寬度
                float textWidth = paint.measureText(number);
                //繪製顏色
                paint.setColor(numberColor);
                //繪製文字大小
                paint.setTextSize(numberSize);
                paint.setStrokeWidth(1);
                canvas.drawText(number, x - textWidth / 2, y + longDegreeLine + SystemUtil.dp2px(getContext(),25), paint);
            }else {
                //畫短刻度
                paint.setColor(lineDegreeColor);
                paint.setStrokeWidth(3);
                canvas.drawLine(x, y, x, y + shortDegreeLine, paint);
            }
            x += lineDegreeSpace;
        }
複製程式碼

執行效果圖如下:

缺數值指標

畫綠色指標

綠色指標底部其實是半圓的,可以在drawable下建立shape檔案,通過bitmap繪製,如:

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
  <solid android:color="#CCCCCC"/>
  <corners
      android:bottomLeftRadius="0px"
      android:bottomRightRadius="30dp"
      android:topLeftRadius="0px"
      android:topRightRadius="30dp"/>
</shape>
複製程式碼

我這裡為了方便,直接用直線來代替,在attrs檔案下增加綠色指標顏色和其寬度:

        <!--指標寬度-->
        <attr name="greenPointWidth" format="dimension"/>
        <!--指標顏色-->
        <attr name="greenPointColor" format="color"/>
複製程式碼

在構造方法新增獲取顏色,粗細:

        //綠色指標粗細
        greenPointWidth = typedArray.getDimension(R.styleable.SlidingRuleView_greenPointWidth, SystemUtil.dp2px(getContext(),4));
        //綠色指標顏色
        greenPointColor = typedArray.getColor(R.styleable.SlidingRuleView_greenPointColor, 0xFF4FBA75);
複製程式碼

因為綠色指標永遠是在View的中間,在onMeasure方法獲取X座標:

        //綠色指標的x座標
        greenPointX =getMeasuredWidth() / 2;
複製程式碼

在onDraw方法繪製指標:

        //畫指標
        paint.setColor(greenPointColor);
        paint.setStrokeWidth(greenPointWidth);
        canvas.drawLine(greenPointX + getScrollX(), y, greenPointX + getScrollX(), y + longDegreeLine + SystemUtil.dp2px(getContext(),3),
                paint);
複製程式碼

這裡x座標為什麼要加上getScrollx(刻度尺的偏移量)呢,因為要保持指標在View的中間位置,不加上的話,指標會隨著刻度移動而移動。

畫當前刻度值

在attrs檔案新增刻度值的顏色和大小,同理在建構函式獲取屬性,這裡就不多講來,因為數字是保留一位的,我這裡用到了DecimalFormat來格式化數字:

        //數字小數點一位
        df = new DecimalFormat("0.0");
複製程式碼

在onDraw繪製:

        //繪製當前刻度值
        //畫當前刻度值
        paint.setColor(currentNumberColor);
        //設定大小
        paint.setTextSize(currentNumberSize);
        //確定數字的值。用移動多少來確定
        currentNum = df.format((greenPointX + getScrollX() - leftBorder) / (lineDegreeSpace * 10.0f));
        //測量數字寬度
        float textWidth = paint.measureText(currentNum);
        canvas.drawText(currentNum, greenPointX - textWidth / 2 + getScrollX(), topDegreeLine - SystemUtil.dp2px(getContext(),15), paint);
複製程式碼

這裡說一下確定數值的方法:

        //確定數字的值。用移動多少來確定
        currentNum = df.format((greenPointX + getScrollX() - leftBorder) / (lineDegreeSpace * 10.0f));
複製程式碼

greenPointX + getScrollX() - leftBorder這條公式是確定指標到刻度尺最左邊的距離是多少,再除以大刻度(每個大刻度有10個小刻度)的距離,就可以得出指標所指的刻度。確定數字的x座標和y座標就不做解釋,很容易理解,因為數字是在刻度尺上面,所以要減去一些距離。

繪製kg

繪製kg無非是在當前刻度值右邊,字型小一點,左右移動先不實現了:

        //畫kg 大小是刻度值的3分之一
        paint.setTextSize(currentNumberSize / 3);
        canvas.drawText("kg", greenPointX + textWidth / 2 + getScrollX() + SystemUtil.dp2px(getContext(),3), topDegreeLine - SystemUtil.dp2px(getContext(),30), paint);
複製程式碼

相比當前刻度值而言,離刻度尺的距離要比當前刻度值要大,我這裡減去30dp。 最終效果:

帶刻度效果
效果和薄荷健康很類似吧,但是這裡注意,因為刻度值只是設定一位小數,也就是綠色指標不能移到兩個小刻度之間,下面處理一下,在觸控方法,增加up方法,也就是當手指抬起時,如果指標滑到兩個刻度值之間,就將綠色指標移動最近的刻度值。

    private void moveRecently(){
        float distance = (greenPointX + getScrollX() - leftBorder) % lineDegreeSpace;
        //指標的位置在小刻度中間位置往後(右)
        if (distance >= lineDegreeSpace / 2) {
            scrollBy((int) (lineDegreeSpace - distance), 0);
        } else {
            scrollBy((int) (-distance), 0);
        }
    }
複製程式碼

注意這裡:

(greenPointX + getScrollX() - leftBorder) % lineDegreeSpace;
複製程式碼

這裡是取餘操作,這裡是確定指標在小刻度之間的具體位置,如果結果小於間隔的一半,那向後(右)最近的刻度移動,如果大於間隔的一半,那就向前(左)最近的刻度移動。到這裡發現,指標不能指向0或者最後的位置,因為綠色指標在View的中間,那麼左右邊界檢測需要改變,需要左邊界減去上View的寬度一半,右邊界需要加上View寬度的一半。

 @Override
    public boolean onTouchEvent(MotionEvent ev){
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                //記錄初始觸控螢幕下的座標
                mXDown = ev.getRawX();
                mLastMoveX = mXDown;
                break;
            case MotionEvent.ACTION_MOVE:
                mCurrentMoveX = ev.getRawX();
                //本次的滑動距離
                int scrolledX = (int) (mLastMoveX - mCurrentMoveX);
                //如果右滑時 內容左邊界超過初始化時候的左邊界 就還是初始化時候的狀態
                if(getScrollX() + scrolledX < leftBorder - getWidth() / 2){
                    scrollTo((int)(- getWidth() / 2 +leftBorder),0);
                    return true;
                }
                //同理 如果左滑  這裡判斷右邊界
                else if(getScrollX() + getWidth() / 2 + scrolledX > rightBorder){
                    scrollTo((int)(rightBorder - getWidth() /2 - ruleRightSpacing),0);
                    return true;
                }else{

                    //左右邊界中 自由滑動
                    scrollBy(scrolledX,0);
                }
                mLastMoveX = mCurrentMoveX;
                break;
            case MotionEvent.ACTION_UP:
                moveRecently();
                break;
        }
        return true;
    }




    private void moveRecently(){
        float distance = (greenPointX + getScrollX() - leftBorder) % lineDegreeSpace;
        //指標的位置在小刻度中間位置往後(右)
        if (distance >= lineDegreeSpace / 2) {
            scrollBy((int) (lineDegreeSpace - distance), 0);
        } else {
            scrollBy((int) (-distance), 0);
        }
    }
複製程式碼

最終效果如下圖:

最終圖

增加慣性滑動

發現這裡沒加慣性滑動,滑動很艱難,下面新增速度追蹤器:

    /**
     * 監控手勢速度類
     */
    private VelocityTracker mVelocityTracker;
    //慣性最大最小速度
    protected int mMaximumVelocity, mMinimumVelocity;
複製程式碼

在初始化方法獲取最大滑動速度,最小滑動速度:

        //新增速度追蹤器
        mVelocityTracker = VelocityTracker.obtain();
        //獲取最大速度
        mMaximumVelocity = ViewConfiguration.get(context)
                .getScaledMaximumFlingVelocity();
        //獲取最小速度
        mMinimumVelocity = ViewConfiguration.get(context)
                .getScaledMinimumFlingVelocity();
複製程式碼

在觸控事件onTouchEvent初始化mVelocityTracker

mVelocityTracker.addMovement(ev);
複製程式碼
 @Override
    public boolean onTouchEvent(MotionEvent ev){
        mVelocityTracker.addMovement(ev);
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                //記錄初始觸控螢幕下的座標
                mXDown = ev.getRawX();
                mLastMoveX = mXDown;
                break;
            case MotionEvent.ACTION_MOVE:
                mCurrentMoveX = ev.getRawX();
                //本次的滑動距離
                int scrolledX = (int) (mLastMoveX - mCurrentMoveX);
                //左右邊界中 自由滑動
                scrollBy(scrolledX,0);
                mLastMoveX = mCurrentMoveX;
                break;
            case MotionEvent.ACTION_UP:
                //處理鬆手後的Fling 獲取當前事件的速率,1毫秒運動了多少個畫素的速率,1000表示一秒
                mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                //獲取橫向速率
                int velocityX = (int) mVelocityTracker.getXVelocity();
                //滑動速度大於最小速度 就滑動
                if (Math.abs(velocityX) > mMinimumVelocity) {
                    fling(-velocityX);
                }
                //刻度之間檢測
                moveRecently();
                break;
            case MotionEvent.ACTION_CANCEL:
                if (mVelocityTracker != null) {
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                }
                break;
        }
        return true;
    }

複製程式碼

發現Move方法呼叫scrollBy方法,ACTION_UP增加了速度速率判斷邏輯,最後呼叫了fling方法:

    private void fling(int vX) {
        mScroller.fling(getScrollX(), 0, vX, 0,(int)(- rightBorder),  (int)rightBorder, 0, 0);
    }
複製程式碼

當使用者手指快速劃過螢幕,手指快速離開螢幕時,系統會判定使用者執行一個Fling手勢,檢視會快速滾動,並且在手指離開螢幕之後也會滾動一定時間。

    /**
     * Start scrolling based on a fling gesture. The distance travelled will
     * depend on the initial velocity of the fling.
     * 
     * @param startX Starting point of the scroll (X)
     * @param startY Starting point of the scroll (Y)
     * @param velocityX Initial velocity of the fling (X) measured in pixels per
     *        second.
     * @param velocityY Initial velocity of the fling (Y) measured in pixels per
     *        second
     * @param minX Minimum X value. The scroller will not scroll past this
     *        point.
     * @param maxX Maximum X value. The scroller will not scroll past this
     *        point.
     * @param minY Minimum Y value. The scroller will not scroll past this
     *        point.
     * @param maxY Maximum Y value. The scroller will not scroll past this
     *        point.
     */
複製程式碼

Scroller的fling函式就是基於手勢滑動,引數的意思:

  • startX:開始滑動的X起點
  • startY:開始滾動的Y起點
  • velocityX:滑動的速度X
  • velocityY:滑動的速度Y
  • minX:X方向的最小值
  • maxX:X方向的最大值
  • minY:Y方向的最小值
  • MaxY:Y方向的最大值

增加computeScroll,這個方法在fling或者startScroll方法,呼叫invalidate方法後執行的函式,並在裡面增加刻度邊界的檢測,完成平滑移動:

    @Override
    public void computeScroll() {
        // 第三步,重寫computeScroll()方法,並在其內部完成平滑滾動的邏輯
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            //這是最後mScroller的最後一次滑動 進行刻度邊界檢測
            if(!mScroller.computeScrollOffset()){
                moveRecently();
            }

        }

    }
複製程式碼

最後重寫scrollTo方法,加刻度尺滑動左右邊界檢測:

//重寫滑動方法,設定到邊界的時候不滑,並顯示邊緣效果。滑動完輸出刻度。
    @Override
    public void scrollTo( int x, int y) {
        //左邊界檢測
        if (x <  leftBorder - getWidth() / 2) {
            x = (int)(- getWidth() / 2 +leftBorder);
        }
        //有邊界檢測
        if (x + getWidth() / 2> rightBorder) {
            x = (int)(rightBorder - getWidth() /2 - ruleRightSpacing);
        }
        if (x != getScrollX()) {

            super.scrollTo(x, y);
        }

    }
複製程式碼

最終執行效果如下:

最終效果圖

總結

每一次練習,小案例都是知識的鞏固和提升。 Demo連結

相關文章