2019-10-31
關鍵字:SwitchButton、狀態開關
完整源碼在文末。
狀態切換開關就是是一個擁有兩種或多種狀態的開關按鈕,可以通過單擊來改變狀態的View。如下圖:
雖然Android官方也有提供了一個 SwitchButton 可以提供兩種狀態之間的切換,但官方嘛,向來是只追求實用,在視覺效果上自然是差強人意。
想要一個既實用又美觀的 SwitchButton,還是自己動手,豐衣足食的好啊。下面就記錄一下筆者通過自定義View 的方式來實現上圖中的SwitchButton。
1、厘需求
在動手寫代碼之前,先要充分厘清我們的需求。只有有了明確的目標才不會在前進過程中迷失方向。
首先筆者這個開關只有兩種狀態:
1、關閉狀態
2、開啟狀態
整個View呈現一個狹長的橢圓形狀,里頭有一個白色小圓圈用於指示開關狀態。當這個小圓圈位於View左側時表示當前處於“關閉”狀態,同時View的背景以灰色顯示。當小圓圈處於View右側時表示當前處於“開啟”狀態,同時View的背景以草綠色表示。
以上是View的靜態需求。
動態需求則是當View的任意位置被點擊時,即自動切換到與當前狀態相反的狀態。同時還支持在狀態切換過程中再次點擊也可以立即響應再切換到另外一個狀態,這種現象用語言比較難描述,直接看下動圖:
2、UI准備
UI 准備其實沒啥,因為這個 View 完全不需要任何外部圖片,只用Android的畫筆就能實現。
唯一需要准備的就是幾種顏色值了。
如上效果圖所示,灰色背景所使用的顏色值為:
<color name="view_switchbtn_shape">#dadbda</color>
草綠色背景的顏色值為:
<color name="view_switchbtn_green_bg">#14b96d</color>
白色小圓圈的顏色值為默認白,即純白色:
android.R.color.white
3、寫代碼
我們的這個View是一個純粹的自定義View,它不是通過組合其它View或圖片或加載.xml來實現的,而是通過繼承 android.view.View 類自行繪制實現的。
首先,我們根據上面厘出來的需求可以知道,我們至少需要三支畫筆:
1、外形畫筆
2、綠色背景畫筆
3、小圓圈畫筆
這三支畫筆的實現如下所示:
private Paint shapePainter; private Paint greenBgPainter; private Paint circlePainter; shapePainter = new Paint(); shapePainter.setColor(ResourcesManager.getColor(R.color.view_switchbtn_shape)); shapePainter.setAntiAlias(true);
greenBgPainter = new Paint(); greenBgPainter.setColor(ResourcesManager.getColor(R.color.view_switchbtn_green_bg)); greenBgPainter.setAntiAlias(true); greenBgPainter.setStyle(Paint.Style.FILL);
circlePainter = new Paint(); circlePainter.setAntiAlias(true); circlePainter.setColor(ResourcesManager.getColor(android.R.color.white));
上面代碼中的 ResourManager 其實就是簡單地封裝了 Context.getResource 而已,就不貼出它的代碼了。
其次,由於灰色背景和綠色背景都屬於圓角矩形,因此我們還需要為它們分別定義 RectF 實例:
private RectF shapeRecf; private RectF greenRecf; shapeRecf = new RectF(); greenRecf = new RectF();
而它們的尺寸則需要在View自身的尺寸被確定之后才能計算,即得在 onMeasure() 方法里去設置值:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int specWidthSize = MeasureSpec.getSize(widthMeasureSpec);//寬 int specHeightSize = MeasureSpec.getSize(heightMeasureSpec);//高 circleRadius = (float)specHeightSize / 2 - 1; cy = (float)specHeightSize / 2; cx = circleRadius + 1; shapeRecf.left = 0; shapeRecf.top = 0; shapeRecf.right = specWidthSize; shapeRecf.bottom = specHeightSize; greenRecf.left = shapeRecf.left; greenRecf.top = shapeRecf.top; greenRecf.bottom = shapeRecf.bottom; middleHori = (float)specWidthSize / 2; moveDistance = (float)specWidthSize / (float)(SWITCH_DURATION_MS / SWITCH_MOVING_INTERVAL); setMeasuredDimension(specWidthSize, specHeightSize); }
關注上面紅色加粗部分的代碼。其中由於綠色背景的寬度是需要跟着小圓圈走的,需要動態計算,因此 greenRecf 的 right 值需要放到 onDraw() 中計算。
然后是定義小圓圈的位置:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int specWidthSize = MeasureSpec.getSize(widthMeasureSpec);//寬 int specHeightSize = MeasureSpec.getSize(heightMeasureSpec);//高 circleRadius = (float)specHeightSize / 2 - 1; cy = (float)specHeightSize / 2; cx = circleRadius + 1; shapeRecf.left = 0; shapeRecf.top = 0; shapeRecf.right = specWidthSize; shapeRecf.bottom = specHeightSize; greenRecf.left = shapeRecf.left; greenRecf.top = shapeRecf.top; greenRecf.bottom = shapeRecf.bottom; middleHori = (float)specWidthSize / 2; moveDistance = (float)specWidthSize / (float)(SWITCH_DURATION_MS / SWITCH_MOVING_INTERVAL); setMeasuredDimension(specWidthSize, specHeightSize); }
同樣關注上面紅色加粗部分的代碼。
circleRadius 即是白色小圓圈的半徑,它的值應該是灰色背景即整個View的高度的一半,但為了增加效果體驗,將它的半徑再減小了 1 個 pixel。同學們有興趣的話可以試一下,看看減了1個像素點和沒減像素點的效果差異。筆者這邊是堅決擁護要減去至少一個像素點的做法的。
然后就是定義小圓圈的圓心坐標 cx, cy 了。按鈕默認處於關閉狀態,故默認圓心位於視圖起始位置。這里同樣需要注意 cx 要空出1個像素點的距離來。
接下來就是繪制這個按鈕了。
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); greenRecf.right = cx + circleRadius + 1; if(cx <= circleRadius + 3){ greenBgPainter.setColor(ResourcesManager.getColor(android.R.color.transparent)); }else{ greenBgPainter.setColor(ResourcesManager.getColor(R.color.view_switchbtn_green_bg)); } canvas.drawRoundRect(shapeRecf, ROUND_CORNER_RADIUS, ROUND_CORNER_RADIUS, shapePainter); canvas.drawRoundRect(greenRecf, ROUND_CORNER_RADIUS, ROUND_CORNER_RADIUS, greenBgPainter); canvas.drawCircle(cx, cy, circleRadius, circlePainter); }
繪制就簡單了,綠色背景的 right 值就是跟着白色小圓圈跑的。同時,因為綠色背景與灰色背景的高度一樣,並且白色小圓圈沒有填滿整個高度,這樣就會造成開關處於關閉狀態時能看到小圓圈外有一層綠色圓環,這種體驗不好。因此筆者這里加了一個條件判斷,當開關處於關閉狀態時,將綠色背景的顏色直接變成透明色。
效果出其的好。
在將白色小圓圈的圓心坐標和綠色背景的 right 值准備好以后就可以繪制了。如上代碼所示,首先繪制最外層灰色背景,其次是綠色背景,最后才是白色小圓圈。上面的 ROUND_CORNER_RADIUS 的值為整個視圖高度的一半,大家自行設定或調試即可。
以上是靜態部分。
動態部分則是要處理View的點擊切換事件與效果。
首先我們得攔截掉View自身的 setOnClickListener 方法,不要讓用戶使用這些老掉牙的方法:
@Override public void setOnClickListener(@Nullable OnClickListener l) { Logger.i(TAG, "Cannot set click listener in this view."); } @Override public void setOnTouchListener(OnTouchListener l) { } @Override public void setOnLongClickListener(@Nullable OnLongClickListener l) { }
做法很簡單,直接重寫這幾個方法,然后置空即可。如果想要增強些體驗,可以再加些提示信息。
然后在這個自定義View的構造方法里主動設置 OnClickedListener 事件的監聽:
public SwitchButton(Context context, AttributeSet attrs) { super(context, attrs);
...
super.setOnClickListener(this); }
這個做法是有點騷,相當於強行攔截了View原本的點擊事件,只給自己用:
@Override public void onClick(View v) { // 1.檢查當前的狀態
// 2.切換到另一狀態
// 3.通知狀態切換結果 }
然后不要忘記要開放出自己的事件監聽接口出來,不然用戶就沒法知道狀態切換結果了:
public void setOnToggleSwitchListener(OnToggleSwitchListener listener){ this.listener = listener; } public interface OnToggleSwitchListener { /** * 開關撥到左邊是未選中狀態,撥到右邊是選中狀態。 * 沒有中間態。 * */ void onToggleSwitched(boolean isSwitched); }
好,事件監聽的問題解決了。下面是切換過程的實現。
所謂切換過程,就是不斷地改變白色小圓圈的位置,再重繪整個View,進而達到在視覺上小圓圈與綠色背景動態變化的效果。
這里我們需要一個定時器,我們還需要定義整個切換過程動畫所持續的時長:
private static final int SWITCH_DURATION_MS = 100; private static final int SWITCH_MOVING_INTERVAL = 10;
以上是切換動畫時長的設定。筆者這里設定的是狀態切換動畫在 100 毫秒內完成,且每 10 毫秒重繪一下狀態。
private class Timer extends CountDownTimer { private boolean is2Right; private Timer(long millisInFuture, long countDownInterval) { super(millisInFuture, countDownInterval); } private void setDirection(boolean isToRight){ is2Right = isToRight; } @Override public void onTick(long millisUntilFinished) { if(is2Right) { cx += moveDistance; if(cx > (shapeRecf.right - circleRadius - 1)){ cx = shapeRecf.right - circleRadius - 1; } }else{ cx -= moveDistance; if(cx < circleRadius + 1){ cx = circleRadius + 1; } } invalidate(); } @Override public void onFinish() { if(is2Right) { cx = shapeRecf.right - circleRadius - 1; }else{ cx = circleRadius + 1; } if(listener != null) { listener.onToggleSwitched(is2Right); } } }
以上是定時器的實現。整個定時器的責任就是根據設定的方向將小圓圈的圓心坐標不斷地改變。上面有一個變量 moveDistance,它就是根據設定的時長、重繪間隔以及View的寬度計算出來的,它的目的就是計算小圓圈每一位置切換時要移動多少距離。
最后,就是View的點擊事件的實現了,簡單:
@Override public void onClick(View v) { timer.cancel(); if(isCurrentChecked()) { timer.setDirection(false); }else{ timer.setDirection(true); } timer.start(); }
好了,整個狀態切換按鈕的思路就是這樣子了。
以下是完整源碼:
package com.demo.apk.views; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.RectF; import android.os.CountDownTimer; import android.util.AttributeSet; import android.view.View; import androidx.annotation.Nullable; import com.unionman.locator.R; import com.unionman.locator.utils.Logger; import com.unionman.locator.utils.ResourcesManager; import com.unionman.locator.utils.UnitManager; public class SwitchButton extends View implements View.OnClickListener { private static final String TAG = "SwitchButton"; private static final float ROUND_CORNER_RADIUS = UnitManager.px2dp(9.0f); private static final int SWITCH_DURATION_MS = 100; private static final int SWITCH_MOVING_INTERVAL = 10; private float circleRadius; private float cx; private float cy; private float middleHori; private float moveDistance; private Paint shapePainter; private Paint greenBgPainter; private Paint circlePainter; private RectF shapeRecf; private RectF greenRecf; private OnToggleSwitchListener listener; private Timer timer; public SwitchButton(Context context) { this(context, null); } public SwitchButton(Context context, AttributeSet attrs) { super(context, attrs); shapePainter = new Paint(); shapePainter.setColor(ResourcesManager.getColor(R.color.view_switchbtn_shape)); shapePainter.setAntiAlias(true); greenBgPainter = new Paint(); greenBgPainter.setColor(ResourcesManager.getColor(R.color.view_switchbtn_green_bg)); greenBgPainter.setAntiAlias(true); greenBgPainter.setStyle(Paint.Style.FILL); circlePainter = new Paint(); circlePainter.setAntiAlias(true); circlePainter.setColor(ResourcesManager.getColor(android.R.color.white)); shapeRecf = new RectF(); greenRecf = new RectF(); timer = new Timer(SWITCH_DURATION_MS, SWITCH_MOVING_INTERVAL); super.setOnClickListener(this); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int specWidthSize = MeasureSpec.getSize(widthMeasureSpec);//寬 int specHeightSize = MeasureSpec.getSize(heightMeasureSpec);//高 circleRadius = (float)specHeightSize / 2 - 1; cy = (float)specHeightSize / 2; cx = circleRadius + 1; shapeRecf.left = 0; shapeRecf.top = 0; shapeRecf.right = specWidthSize; shapeRecf.bottom = specHeightSize; greenRecf.left = shapeRecf.left; greenRecf.top = shapeRecf.top; greenRecf.bottom = shapeRecf.bottom; middleHori = (float)specWidthSize / 2; moveDistance = (float)specWidthSize / (float)(SWITCH_DURATION_MS / SWITCH_MOVING_INTERVAL); setMeasuredDimension(specWidthSize, specHeightSize); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); greenRecf.right = cx + circleRadius + 1; if(cx <= circleRadius + 3){ greenBgPainter.setColor(ResourcesManager.getColor(android.R.color.transparent)); }else{ greenBgPainter.setColor(ResourcesManager.getColor(R.color.view_switchbtn_green_bg)); } canvas.drawRoundRect(shapeRecf, ROUND_CORNER_RADIUS, ROUND_CORNER_RADIUS, shapePainter); canvas.drawRoundRect(greenRecf, ROUND_CORNER_RADIUS, ROUND_CORNER_RADIUS, greenBgPainter); canvas.drawCircle(cx, cy, circleRadius, circlePainter); } @Override public void setOnClickListener(@Nullable OnClickListener l) { Logger.i(TAG, "Cannot set click listener in this view."); } @Override public void setOnTouchListener(OnTouchListener l) { } @Override public void setOnLongClickListener(@Nullable OnLongClickListener l) { } public void setOnToggleSwitchListener(OnToggleSwitchListener listener){ this.listener = listener; } @Override public void onClick(View v) { timer.cancel(); if(isCurrentChecked()) { timer.setDirection(false); }else{ timer.setDirection(true); } timer.start(); } /** * @return true means the button in 'checked' status. * */ public boolean isCurrentChecked(){ return cx > middleHori; } private class Timer extends CountDownTimer { private boolean is2Right; private Timer(long millisInFuture, long countDownInterval) { super(millisInFuture, countDownInterval); } private void setDirection(boolean isToRight){ is2Right = isToRight; } @Override public void onTick(long millisUntilFinished) { if(is2Right) { cx += moveDistance; if(cx > (shapeRecf.right - circleRadius - 1)){ cx = shapeRecf.right - circleRadius - 1; } }else{ cx -= moveDistance; if(cx < circleRadius + 1){ cx = circleRadius + 1; } } invalidate(); } @Override public void onFinish() { if(is2Right) { cx = shapeRecf.right - circleRadius - 1; }else{ cx = circleRadius + 1; } if(listener != null) { listener.onToggleSwitched(is2Right); } } } public interface OnToggleSwitchListener { /** * 開關撥到左邊是未選中狀態,撥到右邊是選中狀態。 * 沒有中間態。 * */ void onToggleSwitched(boolean isSwitched); } }