自定義View實踐 - 仿微信的滑動按鈕

前言

前幾天寫過一篇文章View的工做原理,有原理不行,還要有實踐,恰好把之前項目寫過的仿微信滑動按鈕控件封裝一下,因此本文記錄一下我實現這個控件的細節。java

效果圖

控件使用效果以下:android

除了顏色,看起來和微信的仍是挺像的。git

準備

一、選擇自定義View的方式

自定義View有3種途徑實現:一、組合控件;二、繼承現有控件(如Button);三、繼承View。下面分別介紹一下:github

  • 一、組合控件:咱們並不須要本身去繪製視圖上顯示的內容,而是將幾個系統原生的控件組合到一塊兒,這樣建立出的控件就被稱爲組合控件,好比標題欄就是個很常見的組合控件。
  • 二、繼承現有控件:咱們並不須要本身從新去實現一個控件,只須要去繼承一個現有的控件,而後在這個控件上增長一些新的功能。它的優勢就是不只可以按照咱們的需求加入相應的功能,而且還能夠繼承現有控件已經封裝好的屬性,同時不用本身定義測量流程。
  • 三、繼承View:咱們繼承View,重寫相應的方法,從新去實現一個控件。它的優勢就是靈活性高,它給你一張白紙,你用畫筆盡情發揮。

現實狀況使用什麼方式根據實際狀況考慮,我這個控件的選擇是方式3: 繼承View,重寫onMeasure方法定義它的測量流程,重寫onDraw()方法定義它的繪製流程。canvas

二、選擇讓控件內容滑動的方式

既然是滑動按鈕,確定有滑動,當我點擊按鈕時,若是是打開,按鈕的小圓會滑向右邊,若是是關閉,按鈕的小圓會滑向左邊。讓控件的內容滑動起來我想到的有3種方式:微信

  • 一、經過Scroller:調用Scroller的startScroll()方法,傳入起始點座標和終點座標,而後重寫View的computeScroll()方法,在這個方法裏面調用Scroller的computeScrollOffset()方法開始滑動計算,而後調用View的scrollTo()或scrollBy()方法完成View的滑動距離的更新,而後調用View的invalidate()或postInvalidate()方法重繪View。
  • 二、經過Handler不斷的發送延時消息:經過Handler的 sendMessageDelayed(Message msg, long delayMillis)方法不斷的發送延時消息,在Handler的handlerMessage()中收到消息後,完成滑動距離的更新,而後調用View的invalidate()或postInvalidate()方法重繪View。
  • 三、經過動畫:利用補間動畫或屬性動畫的平移動畫可讓View動起來,或者經過ValueAnimator,設定一個初始值和結點值,當調用ValueAnimator的start()方法後,就能夠在回調中獲取動畫的進度,而後根據動畫的進度更新滑動距離,而後調用View的invalidate()或postInvalidate()方法重繪View。

對於方法1,它更適用於自定義ViewGroup的情景,若是自定義ViewGroup中有許多子View須要滑動起來,就能夠考慮使用Scroller,例如Android的ViewPager內部就是使用了Scroller;而對於自定義View,可能方法2和3更適用,我這個控件的選擇是方式3: 經過ValueAnimator動畫,在構造ValueAnimator時傳入起點和終點,而後開啓動畫,根據動畫進度計算滑動距離,讓按鈕的小圓滑動起來。app

三、要不要考慮padding屬性

若是你在自定義控件中沒有考慮padding屬性,那麼用戶定義控件的padding值就會失效,個人選擇是不考慮用戶的padding值,由於滑動按鈕中的內容只有一個小圓,且只在一邊,padding的意義不大,考慮padding會讓不少地方的座標計算複雜,我還不如讓用戶直接控制小圓的半徑,這樣也相似於padding的效果,也簡化了計算。less

因此現實狀況要不要考慮padding屬性須要根據實際狀況考慮。而margin值是由父ViewGroup決定,不是由View控制的,咱們不用考慮margin值。ide

實現

一、定義控件屬性

在自定義滑動按鈕以前,咱們先思考可讓用戶自定義這個控件的什麼屬性,如按鈕顏色,打開狀態和關閉狀態的顏色等,在 res -> values 中,右鍵新建一個名爲attrs的xml文件,在這個文件中定義控件屬性,以下:函數

<resources>

    <declare-styleable name="SwitchButton" >
        <attr name="sb_openBackground" format="color"/>
        <attr name="sb_closeBackground" format="color"/>
        <attr name="sb_circleColor" format="color"/>
        <attr name="sb_circleRadius" format="dimension"/>
        <attr name="sb_status">
            <enum name="close" value="0"/>
            <enum name="open" value="1"/>
        </attr>
        <attr name="sb_interpolator">
            <enum name="Linear" value="0"/>
            <enum name="Overshoot" value="1"/>
            <enum name="Accelerate" value="2"/>
            <enum name="Decelerate" value="3"/>
            <enum name="AccelerateDecelerate" value="4"/>
            <enum name="LinearOutSlowIn" value="5"/>
        </attr>
    </declare-styleable>

</resources>
複製代碼

這樣用戶在引用這個控件時就能使用這些屬性,以下:

<com.example.library.SwitchButton android:id="@+id/sb_button2" android:layout_width="wrap_content" android:layout_height="wrap_content" app:sb_interpolator="Accelerate" app:sb_status="open" app:sb_circleRadius="10dp" app:sb_closeBackground="@android:color/black" app:sb_openBackground="@android:color/holo_blue_bright" app:sb_circleColor="@android:color/white" />
複製代碼

屬性的名稱要作到見名知意,app只是一個命名空間,取什麼名字均可以,不要和系統android相同就行。關於這些屬性什麼意思能夠看SwitchButton

二、初始化控件屬性

重寫View的3個構造方法,分別在3個構造函數中調用init()方法獲取控件屬性並初始化控件,以下:

public class SwitchButton extends View {
    public SwitchButton(Context context) {
        super(context);
        init(context, null);
    }

    public SwitchButton(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context, attrs);
    }

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

    private void init(Context context, @Nullable AttributeSet attrs) {
        TypedArray typedValue = context.obtainStyledAttributes(attrs, R.styleable.SwitchButton);
        mOpenBackground = typedValue.getColor(R.styleable.SwitchButton_sb_openBackground, DEFAULT_OPEN_BACKGROUND);
        mCloseBackground = typedValue.getColor(R.styleable.SwitchButton_sb_closeBackground, DEFAULT_CLOSE_BACKGROUND);
        //...
        typedValue.recycle();
         //...
        //初始畫筆,動畫等
    }
}
複製代碼

咱們在attrs中定義的控件屬性都在AttributeSet這個集合中,而後經過TypedArray這個類幫助咱們把值獲取出來,最後必定要記得調用 typedValue.recycle() 方法回收資源。

爲何要重寫3個構造函數呢?由於你的控件有可能在代碼中引用或者在xml佈局中引用,若是你的控件在xml佈局中被引用,那麼系統就會調用含有兩個參數的構造函數來初始化控件;若是你直接在代碼中 new 一個控件而後 add 到容器中,那麼大多數狀況你會使用含有一個參數的構造函數來初始化控件,如:SwitchButton button = new SwitchButton(this),而無論一個參數的仍是兩個參數的系統最終都會調用含有三個參數的構造函數,以防萬一,3個構造函數都要重寫。

三、重寫onMeasure方法,設定按鈕的測量寬高

重寫onMeasure方法在這個方法設定滑動控件的測量寬高,以下:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int measuredWidthMode = MeasureSpec.getMode(widthMeasureSpec);
    int measuredHeightMode = MeasureSpec.getMode(heightMeasureSpec);
    //取出系統測量寬高
    int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
    int measureHeight = MeasureSpec.getSize(heightMeasureSpec);
    
    int defaultWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 60, getResources().getDisplayMetrics());//控件的默認寬
    int defaultHeight = (int) (defaultWidth *  0.5f);//控件的默認高是默認寬的一半
    
    //OFFSET == 6
    int offset = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, OFFSET * 2 * 1.0f, getResources().getDisplayMetrics());//控件寬和高的差距不能小於12dp, 不然按鈕就很差看了
    
    //考慮wrap_content狀況
    if(measuredWidthMode == MeasureSpec.AT_MOST && measuredHeightMode == MeasureSpec.AT_MOST){
        measuredWidth = defaultWidth;
        measureHeight = defaultHeight;
    }else if(measuredHeightMode == MeasureSpec.AT_MOST){
        measureHeight = defaultHeight;
        if(measuredWidth - measureHeight < offset)
            measuredWidth = defaultWidth;
    }else if(measuredWidthMode == MeasureSpec.AT_MOST){
        measuredWidth = defaultWidth;
        if(measuredWidth - measureHeight < offset)
            measureHeight = defaultHeight;
    }else {
        //處理輸入非法的寬高狀況,即高度大於寬度,把它們交換就行
        if(measuredWidth < measureHeight){
            int temp = measuredWidth;
            measuredWidth = measureHeight;
            measureHeight = temp;
        }
    }
    
    if(Math.abs(measureHeight - measuredWidth) < offset) throw new IllegalArgumentException("layout_width cannot close to layout_height nearly, the diff must less than 12dp!");
    
    setMeasuredDimension(measuredWidth, measureHeight);
    
}
複製代碼

若是知道View的工做原理,那麼理解上面的代碼就很簡單,主要是考慮wrap_content狀況,咱們要給滑動按鈕設置一個默認的寬或高,默認的寬是60dp,默認高是30dp即寬的一半,若是不是wrap_content狀況就讓View直接使用系統測量的寬或高,最後必定要記得調用setMeasuredDimension()設定View的測量寬高。

同時咱們還要考慮理輸入非法的寬高狀況,必定要保證寬 > 高,若是用戶輸入的寬高是 寬 < 高,這樣會致使按鈕豎起來,這種狀況,我直接讓高度與寬度交換;若是用戶輸入的寬高是 寬 > 高,可是若是高很接近寬甚至相等,那麼致使滑動控件就是一個圓形,按鈕就很差看了,因此咱們還要控制寬高不能相差得太近,爲了美觀,我設定閾值是12dp,若是寬高相差小於12dp,我就拋個異常提示用戶。

四、在onLayout()方法中根據View的寬高計算座標

滑動控件被分爲4個部分:左圓、矩形、右圓、小圓,以下:

在onDraw()方法中也會按順序繪製滑動按鈕的4個部分,在View的工做原理中講到,onMeasure()有可能會被系統調用屢次,因此最好在onLayout()方法中經過getHeight()和getWidth()方法得到View的真實寬高,因此在onLayout()方法中首先根據View的寬高計算出左圓的半徑,小圓的半徑,矩形左邊界的x座標,矩形右邊界的x座標,還有小圓圓心的x座標,以下:

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    //得出左圓的半徑
    mLeftSemiCircleRadius = getHeight() / 2;
    //小圓的半徑 = 大圓半徑減OFFER,OFFER = 6
    if(!checkCircleRaduis(mCircleRadius)) mCircleRadius = mLeftSemiCircleRadius - OFFSET;
    //矩形左邊的x座標
    mLeftRectangleBolder = mLeftSemiCircleRadius;
    //矩形右邊的x座標
    mRightRectangleBolder = getWidth() - mLeftSemiCircleRadius;
    //小圓的圓心x座標一直在變化
    mCircleCenter = isOpen ? mRightRectangleBolder : mLeftRectangleBolder;
}
複製代碼

能夠看到左圓的半徑等於View高的一半,而後基於左圓的半徑得出其餘座標,小圓與左圓之間會有一些空隙,因此左圓半徑減去offset值得出小圓半徑,矩形左邊的x座標直接等於左圓的半徑,矩形右邊的x座標View的寬度減左圓的半徑,小圓圓心的x座標根據初始狀態是開啓仍是關閉,決定它的圓心的初始座標是在矩形的右邊界仍是左邊界。

在接下來只要你不斷的改變小圓圓心的x座標並重繪View,就可讓滑動按鈕滑動起來。

五、重寫onDraw()方法,繪製按鈕內容

View的工做原理中咱們知道,View會在onDraw()方法中繪製本身,因此咱們重寫onDraw()方法,繪製滑動按鈕的四個部分,以下:

@Override
protected void onDraw(Canvas canvas) {
    //左圓
    canvas.drawCircle(mLeftRectangleBolder, mLeftSemiCircleRadius, mLeftSemiCircleRadius, mPathWayPaint);
    //矩形
    canvas.drawRect(mLeftRectangleBolder, 0, mRightRectangleBolder, getMeasuredHeight(), mPathWayPaint);
    //右圓
    canvas.drawCircle(mRightRectangleBolder, mLeftSemiCircleRadius, mLeftSemiCircleRadius, mPathWayPaint);
    //小圓
    canvas.drawCircle(mCircleCenter, mLeftSemiCircleRadius, mCircleRadius, mCirclePaint);
}

複製代碼

canvas是系統提供給咱們的畫布,在canvas繪製的東西就是View顯示的內容,根據在onLayout中的計算,咱們用畫筆Paint在canvas中繪製出滑動按鈕的4個部分,繪製後顯示以下:

接下來就是讓它滑動起來,這樣就能達到效果圖的效果。

六、重寫onTouchEvent()方法,讓按鈕滑動起來

View的事件分發機制講到,觸摸事件若是不被攔截,最終會分發到View的onTouchEvent()方法中,在這個方法中咱們能夠根據事件的類型作出滑動按鈕的不一樣行爲,咱們知道當手指按下按鈕而後擡起,滑動按鈕的小圓就會滑動到另外一邊;當手指按下按鈕而後移動,滑動按鈕的小圓也會跟隨手指移動,知道了這兩個行爲後,咱們看onTouchEvent()方法以下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    //不在動畫的時候能夠點擊
    if(isAnim) return false;
    switch(event.getAction()){
        case MotionEvent.ACTION_DOWN:
            //開始的x座標
            startX = event.getX();
            break;
        case MotionEvent.ACTION_MOVE:
            float distance = event.getX() - startX;
            //更新小圓圓心座標
            mCircleCenter += distance / 10;
            //控制範圍
            if (mCircleCenter > mRightRectangleBolder) {//最右
                mCircleCenter = mRightRectangleBolder;
            } else if (mCircleCenter < mLeftRectangleBolder) {//最左
                mCircleCenter = mLeftRectangleBolder;
            }
            invalidate();
            break;
        case MotionEvent.ACTION_UP:
            float offset = Math.abs(event.getX() - Math.abs(startX));
            float diff;
            //分2種狀況
            if (offset < mMinDistance) { //1.點擊, 按下和擡起的距離小於mMinDistance肯定是點擊了
                if(isOpen){
                    diff = mLeftRectangleBolder - mCircleCenter;
                }else{
                    diff = mRightRectangleBolder - mCircleCenter;
                }
            } else {//2.滑動
                if (mCircleCenter > getWidth() / 2) {//滑過中點,滑到最右
                    this.isOpen = false;
                    diff = mRightRectangleBolder - mCircleCenter;
                } else{//沒滑過中點,迴歸原點
                    this.isOpen = true;
                    diff = mLeftRectangleBolder - mCircleCenter;
                }
            }
            mValueAnimator.setFloatValues(0, diff);
            mValueAnimator.start();
            startX = 0;
            break;
        default:
            break;
    }
    return true;
}

複製代碼

咱們先看ACTION_DOWN,當手指按下,咱們記錄手指按下的x座標。

接着看ACTION_MOVE,若是按下後移動,咱們就讓小圓跟隨手指移動便可,因此ACTION_MOVE中先計算出手指移動的距離distance,往右移distance是正數,往左移distance是負數,而後加到小圓的圓心座標,還要控制小圓的圓心座標的範圍,不要超出矩形左右邊界,最後調用 invalidate()重繪View,這樣onDraw()方法就會從新執行,更新小圓的位置,就會讓小圓慢慢滑動起來。

最後看ACTION_UP,mMinDistance = new ViewConfiguration().getScaledTouchSlop(),它是系統定義的臨界值,當擡起手指時,若是移動的距離offset大於mMinDistance ,就認爲擡起手指前,手指在移動,不然就認爲在點擊。若是手指在移動後擡起,這時就判斷小圓圓心是否滑過中點算出滑動距離,若是滑過中點(getWidth() / 2),就讓小圓滑到最右,若是沒有滑過中點,就讓小圓滑到最左;若是手指只是在點擊控件,這時就根據控件目前處於開啓仍是關閉狀態算出滑動距離,若是目前處於開啓狀態,就讓小圓滑到最左,若是目前處於關閉狀態就讓小圓滑到最右;而這個滑動距離diff就是小圓圓心到矩形邊界的距離,至因而距離左邊界仍是右邊界,就看上述狀況了,計算出滑動距離後設置給ValueAnimator,最後開啓動畫,在ValueAnimator的updateListener中接收動畫進度,以下:

mValueAnimator.addUpdateListener(animation -> {
    float value = (float)animation.getAnimatedValue();
    mCircleCenter -= mPreAnimatedValue;
    //更新小圓圓心座標
    mCircleCenter += value;
    mPreAnimatedValue = value;
    invalidate();
});
複製代碼

在裏面根據動畫進度更新小圓圓心座標,而後調用 invalidate()重繪View,這樣onDraw()方法就會從新執行,更新小圓的位置,這樣重複執行直到動畫結束,就會讓小圓慢慢滑動起來。

結語

到最後就已經實現了效果圖的效果,整個過程的原理仍是挺簡單,使用到了動畫還有自定義View的基礎知識,趕快動手實踐一下。

地址:SwitchButton

相關文章
相關標籤/搜索