前言
前幾天寫過一篇文章View的工作原理,有原理不行,還要有實踐,剛好把以前項目寫過的仿微信滑動按鈕控件封裝一下,所以本文記錄一下我實現(xiàn)這個控件的細節(jié)。
地址:SwitchButton
效果圖
控件使用效果如下:

除了顏色,看起來和微信的還是挺像的。
準備
1、選擇自定義View的方式
自定義View有3種途徑實現(xiàn):1、組合控件;2、繼承現(xiàn)有控件(如Button);3、繼承View。下面分別介紹一下:
- 1、組合控件:我們并不需要自己去繪制視圖上顯示的內(nèi)容,而是將幾個系統(tǒng)原生的控件組合到一起,這樣創(chuàng)建出的控件就被稱為組合控件,比如標題欄就是個很常見的組合控件。
- 2、繼承現(xiàn)有控件:我們并不需要自己重新去實現(xiàn)一個控件,只需要去繼承一個現(xiàn)有的控件,然后在這個控件上增加一些新的功能。它的優(yōu)點就是不僅能夠按照我們的需求加入相應的功能,并且還可以繼承現(xiàn)有控件已經(jīng)封裝好的屬性,同時不用自己定義測量流程。
- 3、繼承View:我們繼承View,重寫相應的方法,重新去實現(xiàn)一個控件。它的優(yōu)點就是靈活性高,它給你一張白紙,你用畫筆盡情發(fā)揮。
現(xiàn)實情況使用什么方式根據(jù)實際情況考慮,我這個控件的選擇是方式3: 繼承View,重寫onMeasure方法定義它的測量流程,重寫onDraw()方法定義它的繪制流程。
2、選擇讓控件內(nèi)容滑動的方式
既然是滑動按鈕,肯定有滑動,當我點擊按鈕時,如果是打開,按鈕的小圓會滑向右邊,如果是關閉,按鈕的小圓會滑向左邊。讓控件的內(nèi)容滑動起來我想到的有3種方式:
- 1、通過Scroller:調(diào)用Scroller的startScroll()方法,傳入起始點坐標和終點坐標,然后重寫View的computeScroll()方法,在這個方法里面調(diào)用Scroller的computeScrollOffset()方法開始滑動計算,然后調(diào)用View的scrollTo()或scrollBy()方法完成View的滑動距離的更新,然后調(diào)用View的invalidate()或postInvalidate()方法重繪View。
- 2、通過Handler不斷的發(fā)送延時消息:通過Handler的 sendMessageDelayed(Message msg, long delayMillis)方法不斷的發(fā)送延時消息,在Handler的handlerMessage()中收到消息后,完成滑動距離的更新,然后調(diào)用View的invalidate()或postInvalidate()方法重繪View。
- 3、通過動畫:利用補間動畫或?qū)傩詣赢嫷钠揭苿赢嬁梢宰孷iew動起來,或者通過ValueAnimator,設定一個初始值和結(jié)點值,當調(diào)用ValueAnimator的start()方法后,就可以在回調(diào)中獲取動畫的進度,然后根據(jù)動畫的進度更新滑動距離,然后調(diào)用View的invalidate()或postInvalidate()方法重繪View。
對于方法1,它更適用于自定義ViewGroup的情景,如果自定義ViewGroup中有許多子View需要滑動起來,就可以考慮使用Scroller,例如Android的ViewPager內(nèi)部就是使用了Scroller;而對于自定義View,可能方法2和3更適用,我這個控件的選擇是方式3: 通過ValueAnimator動畫,在構(gòu)造ValueAnimator時傳入起點和終點,然后開啟動畫,根據(jù)動畫進度計算滑動距離,讓按鈕的小圓滑動起來。
3、要不要考慮padding屬性
如果你在自定義控件中沒有考慮padding屬性,那么用戶定義控件的padding值就會失效,我的選擇是不考慮用戶的padding值,因為滑動按鈕中的內(nèi)容只有一個小圓,且只在一邊,padding的意義不大,考慮padding會讓很多地方的坐標計算復雜,我還不如讓用戶直接控制小圓的半徑,這樣也類似于padding的效果,也簡化了計算。
所以現(xiàn)實情況要不要考慮padding屬性需要根據(jù)實際情況考慮。而margin值是由父ViewGroup決定,不是由View控制的,我們不用考慮margin值。
實現(xiàn)
1、定義控件屬性
在自定義滑動按鈕之前,我們先思考可以讓用戶自定義這個控件的什么屬性,如按鈕顏色,打開狀態(tài)和關閉狀態(tài)的顏色等,在 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只是一個命名空間,取什么名字都可以,不要和系統(tǒng)android相同就行。關于這些屬性什么意思可以看SwitchButton。
2、初始化控件屬性
重寫View的3個構(gòu)造方法,分別在3個構(gòu)造函數(shù)中調(diào)用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這個類幫助我們把值獲取出來,最后一定要記得調(diào)用 typedValue.recycle() 方法回收資源。
為什么要重寫3個構(gòu)造函數(shù)呢?因為你的控件有可能在代碼中引用或者在xml布局中引用,如果你的控件在xml布局中被引用,那么系統(tǒng)就會調(diào)用含有兩個參數(shù)的構(gòu)造函數(shù)來初始化控件;如果你直接在代碼中 new 一個控件然后 add 到容器中,那么大多數(shù)情況你會使用含有一個參數(shù)的構(gòu)造函數(shù)來初始化控件,如:SwitchButton button = new SwitchButton(this),而不管一個參數(shù)的還是兩個參數(shù)的系統(tǒng)最終都會調(diào)用含有三個參數(shù)的構(gòu)造函數(shù),以防萬一,3個構(gòu)造函數(shù)都要重寫。
3、重寫onMeasure方法,設定按鈕的測量寬高
重寫onMeasure方法在這個方法設定滑動控件的測量寬高,如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int measuredWidthMode = MeasureSpec.getMode(widthMeasureSpec);
int measuredHeightMode = MeasureSpec.getMode(heightMeasureSpec);
//取出系統(tǒng)測量寬高
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直接使用系統(tǒng)測量的寬或高,最后一定要記得調(diào)用setMeasuredDimension()設定View的測量寬高。
同時我們還要考慮理輸入非法的寬高情況,一定要保證寬 > 高,如果用戶輸入的寬高是 寬 < 高,這樣會導致按鈕豎起來,這種情況,我直接讓高度與寬度交換;如果用戶輸入的寬高是 寬 > 高,但是如果高很接近寬甚至相等,那么導致滑動控件就是一個圓形,按鈕就不好看了,所以我們還要控制寬高不能相差得太近,為了美觀,我設定閾值是12dp,如果寬高相差小于12dp,我就拋個異常提示用戶。
4、在onLayout()方法中根據(jù)View的寬高計算坐標
滑動控件被分為4個部分:左圓、矩形、右圓、小圓,如下:

在onDraw()方法中也會按順序繪制滑動按鈕的4個部分,在View的工作原理中講到,onMeasure()有可能會被系統(tǒng)調(diào)用多次,所以最好在onLayout()方法中通過getHeight()和getWidth()方法獲得View的真實寬高,所以在onLayout()方法中首先根據(jù)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坐標根據(jù)初始狀態(tài)是開啟還是關閉,決定它的圓心的初始坐標是在矩形的右邊界還是左邊界。
在接下來只要你不斷的改變小圓圓心的x坐標并重繪View,就可以讓滑動按鈕滑動起來。
5、重寫onDraw()方法,繪制按鈕內(nèi)容
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是系統(tǒng)提供給我們的畫布,在canvas繪制的東西就是View顯示的內(nèi)容,根據(jù)在onLayout中的計算,我們用畫筆Paint在canvas中繪制出滑動按鈕的4個部分,繪制后顯示如下:

接下來就是讓它滑動起來,這樣就能達到效果圖的效果。
6、重寫onTouchEvent()方法,讓按鈕滑動起來
在View的事件分發(fā)機制講到,觸摸事件如果不被攔截,最終會分發(fā)到View的onTouchEvent()方法中,在這個方法中我們可以根據(jù)事件的類型做出滑動按鈕的不同行為,我們知道當手指按下按鈕然后抬起,滑動按鈕的小圓就會滑動到另一邊;當手指按下按鈕然后移動,滑動按鈕的小圓也會跟隨手指移動,知道了這兩個行為后,我們看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是正數(shù),往左移distance是負數(shù),然后加到小圓的圓心坐標,還要控制小圓的圓心坐標的范圍,不要超出矩形左右邊界,最后調(diào)用 invalidate()重繪View,這樣onDraw()方法就會重新執(zhí)行,更新小圓的位置,就會讓小圓慢慢滑動起來。
最后看ACTION_UP,mMinDistance = new ViewConfiguration().getScaledTouchSlop(),它是系統(tǒng)定義的臨界值,當抬起手指時,如果移動的距離offset大于mMinDistance ,就認為抬起手指前,手指在移動,否則就認為在點擊。如果手指在移動后抬起,這時就判斷小圓圓心是否滑過中點算出滑動距離,如果滑過中點(getWidth() / 2),就讓小圓滑到最右,如果沒有滑過中點,就讓小圓滑到最左;如果手指只是在點擊控件,這時就根據(jù)控件目前處于開啟還是關閉狀態(tài)算出滑動距離,如果目前處于開啟狀態(tài),就讓小圓滑到最左,如果目前處于關閉狀態(tài)就讓小圓滑到最右;而這個滑動距離diff就是小圓圓心到矩形邊界的距離,至于是距離左邊界還是右邊界,就看上述情況了,計算出滑動距離后設置給ValueAnimator,最后開啟動畫,在ValueAnimator的updateListener中接收動畫進度,如下:
mValueAnimator.addUpdateListener(animation -> {
float value = (float)animation.getAnimatedValue();
mCircleCenter -= mPreAnimatedValue;
//更新小圓圓心坐標
mCircleCenter += value;
mPreAnimatedValue = value;
invalidate();
});
在里面根據(jù)動畫進度更新小圓圓心坐標,然后調(diào)用 invalidate()重繪View,這樣onDraw()方法就會重新執(zhí)行,更新小圓的位置,這樣重復執(zhí)行直到動畫結(jié)束,就會讓小圓慢慢滑動起來。
結(jié)語
到最后就已經(jīng)實現(xiàn)了效果圖的效果,整個過程的原理還是挺簡單,使用到了動畫還有自定義View的基礎知識,趕快動手實踐一下。
地址:SwitchButton