天天看點

Android 自定義控件之 SwitchButton(仿 iOS 開關)1 思路 2 圓角矩形背景 3 繪制按鈕訓示器 4 動起來 5 動畫 6 總結 7 完整代碼

Android 自定義控件之 SwitchButton(仿 iOS 開關)1 思路 2 圓角矩形背景 3 繪制按鈕訓示器 4 動起來 5 動畫 6 總結 7 完整代碼

上圖中的按鈕是 iOS 中的自帶的開關控件,Android 也有很多優秀的仿這個控件的開源庫,自己也是模仿着實作了一下,下面記錄一下實作過程。

1 思路

首先還是來進行分解動作,從靜态樣子來看,這個開關是一個圓角矩形的背景,然後中間有一個圓形的東西(姑且叫做按鈕訓示器)。點選該控件的時候會像 CheckBox 一樣,會在開和關的狀态之間切換,這個切換不是瞬間完成的,中間的訓示器會有一個從左移動到右或從右移動到左的動畫,背景也會慢慢過渡變化。

既然剛才說這個控件像 CheckBox 一樣,會在兩種狀态之間切換,是以我們繼承 CheckBox 就好。

2 圓角矩形背景

既然理清了思路,那麼開始動手,從背景開始。

繪制一個圓角矩形的背景很簡單,調用原生 api 即可:

public class SwitchButton extends android.support.v7.widget.AppCompatCheckBox {
    private static final String TAG = "SwitchButton";
    private static final int DEFAULT_WIDTH = 200;
    private static final int DEFAULT_HEIGHT = DEFAULT_WIDTH / 8 * 5;
    
    private Paint mPaint;
    private RectF mRectF;

    public SwitchButton(Context context) {
        this(context, null);
    }

    public SwitchButton(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public SwitchButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setButtonDrawable(null);
        setBackgroundResource(0);

        mPaint = new Paint();
        mPaint.setAntiAlias(true);

        mRectF = new RectF();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int width;
        int height;
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            width =  (getPaddingLeft() + DEFAULT_WIDTH + getPaddingRight());
        }

        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            height = (getPaddingTop() + DEFAULT_HEIGHT + getPaddingBottom());
        }
        setMeasuredDimension(width, height);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPaint.setStrokeWidth((float) getMeasuredWidth() / 40);
        mPaint.setColor(0xFFCCCCCC);
        mRectF.set(mPaint.getStrokeWidth()
                , mPaint.getStrokeWidth()
                , getMeasuredWidth() - mPaint.getStrokeWidth()
                , getMeasuredHeight() - mPaint.getStrokeWidth());
        canvas.drawRoundRect(mRectF, getMeasuredHeight(), getMeasuredHeight(), mPaint);
    }
}
           

效果如下:

Android 自定義控件之 SwitchButton(仿 iOS 開關)1 思路 2 圓角矩形背景 3 繪制按鈕訓示器 4 動起來 5 動畫 6 總結 7 完整代碼

3 繪制按鈕訓示器

按鈕訓示器就是一個圓形,它的半徑可以了解為就是控件高度的一半,不過為了好看一點可以讓它有點内邊距,多減去一個畫筆寬度,在 onDraw 方法中增加如下代碼:

mPaint.setColor(0xFFFFFFFF);
float radius = (getMeasuredHeight() - mPaint.getStrokeWidth() * 4) / 2;
float x = mPaint.getStrokeWidth() + mPaint.getStrokeWidth() + radius;
float y = (float) getMeasuredHeight() / 2;
canvas.drawCircle(x, y, radius, mPaint);
           

現在效果如下:

Android 自定義控件之 SwitchButton(仿 iOS 開關)1 思路 2 圓角矩形背景 3 繪制按鈕訓示器 4 動起來 5 動畫 6 總結 7 完整代碼

4 動起來

樣子有了,現在我們來做功能。在點選這個控件的時候,因為它是繼承自 CheckBox 的,是以它的 checked 屬性會改變,我們根據這個屬性就可以繪制不同 UI。主要是在繪制背景時根據不同狀态給畫筆設定不同顔色,還有在繪制按鈕訓示器時根據不同狀态,圓心 x 坐标一個在左,一個在右,y 坐标不變,在左邊的時候就是半徑加上兩個畫筆寬度,在右邊的時候就是控件寬度減去半徑,再減去兩個畫筆寬度而已。

修改 onDraw 方法:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    mPaint.setStrokeWidth((float) getMeasuredWidth() / 40);
    if (isChecked()) {
        mPaint.setColor(0xFF6495ED);
    } else {
        mPaint.setColor(0xFFCCCCCC);
    }
    mRectF.set(mPaint.getStrokeWidth()
            , mPaint.getStrokeWidth()
            , getMeasuredWidth() - mPaint.getStrokeWidth()
            , getMeasuredHeight() - mPaint.getStrokeWidth());
    canvas.drawRoundRect(mRectF, getMeasuredHeight(), getMeasuredHeight(), mPaint);

    mPaint.setColor(0xFFFFFFFF);
    float radius = (getMeasuredHeight() - mPaint.getStrokeWidth() * 4) / 2;
    float x;
    float y;
    if (isChecked()) {
        x = getMeasuredWidth() - radius - mPaint.getStrokeWidth() - mPaint.getStrokeWidth();
    } else {
        x = mPaint.getStrokeWidth() + radius + mPaint.getStrokeWidth();
    }
    y = (float) getMeasuredHeight() / 2;
    canvas.drawCircle(x, y, radius, mPaint);
}
           

在構造方法中添加一個 OnClickListener,在控件被點選時重新繪制 UI:

setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        invalidate();
    }
});
           

現在效果如下:

Android 自定義控件之 SwitchButton(仿 iOS 開關)1 思路 2 圓角矩形背景 3 繪制按鈕訓示器 4 動起來 5 動畫 6 總結 7 完整代碼

如果不考慮動畫的話,這個開關控件的基本效果已經有了。

5 動畫

動畫主要分兩個部分,一個是按鈕訓示器位置(圓心)切換的動畫,另一個就是背景顔色的過渡動畫。這兩個動畫我都選擇用屬性動畫來做。

我們可以計算按鈕訓示器在左右兩邊時的圓心的 x 坐标的差,然後通過屬性動畫讓這個值慢慢變為 0,這個值可以看作是 x 坐标的偏移量,從左到右時 x 坐标減去這個偏移量,從右到左時 x 坐标加上這個偏移量。

背景顔色的過渡從度娘上找了一個方法:

private int getCurrentColor(float fraction, int startColor, int endColor) {
    int redStart = Color.red(startColor);
    int blueStart = Color.blue(startColor);
    int greenStart = Color.green(startColor);
    int alphaStart = Color.alpha(startColor);

    int redEnd = Color.red(endColor);
    int blueEnd = Color.blue(endColor);
    int greenEnd = Color.green(endColor);
    int alphaEnd = Color.alpha(endColor);

    int redDifference = redEnd - redStart;
    int blueDifference = blueEnd - blueStart;
    int greenDifference = greenEnd - greenStart;
    int alphaDifference = alphaEnd - alphaStart;

    int redCurrent = (int) (redStart + fraction * redDifference);
    int blueCurrent = (int) (blueStart + fraction * blueDifference);
    int greenCurrent = (int) (greenStart + fraction * greenDifference);
    int alphaCurrent = (int) (alphaStart + fraction * alphaDifference);

    return Color.argb(alphaCurrent, redCurrent, greenCurrent, blueCurrent);
}
           

這個方法可以擷取一個過渡期中目前顔色,fraction 為過渡系數,取值範圍 0f-1f,值越接近 1,顔色就越接近 endColor。我們仍然通過屬性動畫來修改過渡系數即可。

主要代碼如下:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPaint.setStrokeWidth((float) getMeasuredWidth() / 40);
        if (isChecked()) {
            mPaint.setColor(getCurrentColor(mColorGradientFactor, 0xFFCCCCCC, 0xFF6495ED));
        } else {
            mPaint.setColor(getCurrentColor(mColorGradientFactor, 0xFF6495ED, 0xFFCCCCCC));
        }
        mRectF.set(mPaint.getStrokeWidth()
                , mPaint.getStrokeWidth()
                , getMeasuredWidth() - mPaint.getStrokeWidth()
                , getMeasuredHeight() - mPaint.getStrokeWidth());
        canvas.drawRoundRect(mRectF, getMeasuredHeight(), getMeasuredHeight(), mPaint);

        mPaint.setColor(0xFFFFFFFF);
        float radius = (getMeasuredHeight() - mPaint.getStrokeWidth() * 4) / 2;
        float x;
        float y;
        if (isChecked()) {
//            x = getMeasuredWidth() - radius - mPaint.getStrokeWidth() - mPaint.getStrokeWidth();
            x = getMeasuredWidth() - radius - mPaint.getStrokeWidth() - mPaint.getStrokeWidth() - mButtonCenterXOffset;
        } else {
//            x = radius + mPaint.getStrokeWidth() + mPaint.getStrokeWidth();
            x = radius + mPaint.getStrokeWidth() + mPaint.getStrokeWidth() + mButtonCenterXOffset;
        }
        y = (float) getMeasuredHeight() / 2;
        canvas.drawCircle(x, y, radius, mPaint);
    }

    private void startAnimate() {
        // 計算開關訓示器的半徑
        float radius = (getMeasuredHeight() - mPaint.getStrokeWidth() * 4) / 2;
        // 計算開關訓示器的 X 坐标的總偏移量
        float centerXOffset = getMeasuredWidth() - mPaint.getStrokeWidth() - mPaint.getStrokeWidth() - radius
                - (mPaint.getStrokeWidth() + mPaint.getStrokeWidth() + radius);

        AnimatorSet animatorSet = new AnimatorSet();
        // 偏移量逐漸變化到 0
        ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(this, "buttonCenterXOffset", centerXOffset, 0);
        objectAnimator.setDuration(2000);
        objectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                invalidate();
            }
        });

        // 背景顔色過渡系數逐漸變化到 1
        ObjectAnimator objectAnimator2 = ObjectAnimator.ofFloat(this, "colorGradientFactor", 0, 1);
        objectAnimator2.setDuration(2000);

        // 同時開始修改開關訓示器 X 坐标偏移量的動畫和修改背景顔色過渡系數的動畫
        animatorSet.play(objectAnimator).with(objectAnimator2);
        animatorSet.start();
    }

    private int getCurrentColor(float fraction, int startColor, int endColor) {
        int redStart = Color.red(startColor);
        int blueStart = Color.blue(startColor);
        int greenStart = Color.green(startColor);
        int alphaStart = Color.alpha(startColor);

        int redEnd = Color.red(endColor);
        int blueEnd = Color.blue(endColor);
        int greenEnd = Color.green(endColor);
        int alphaEnd = Color.alpha(endColor);

        int redDifference = redEnd - redStart;
        int blueDifference = blueEnd - blueStart;
        int greenDifference = greenEnd - greenStart;
        int alphaDifference = alphaEnd - alphaStart;

        int redCurrent = (int) (redStart + fraction * redDifference);
        int blueCurrent = (int) (blueStart + fraction * blueDifference);
        int greenCurrent = (int) (greenStart + fraction * greenDifference);
        int alphaCurrent = (int) (alphaStart + fraction * alphaDifference);

        return Color.argb(alphaCurrent, redCurrent, greenCurrent, blueCurrent);
    }
           

點選事件要修改成調用 startAnimate 方法,現在效果如下:

Android 自定義控件之 SwitchButton(仿 iOS 開關)1 思路 2 圓角矩形背景 3 繪制按鈕訓示器 4 動起來 5 動畫 6 總結 7 完整代碼

為了示範,是以把動畫時長設定為 2000ms,實際上 300ms 的效果挺好的,然後上面隻是部分代碼,大家主要看思路。完整代碼在最後貼出,我們還可以考慮封裝一些 api 供外部友善的使用,如設定按鈕顔色,背景顔色,動畫時長等。

6 總結

這個控件在我剛開始工作的時候就差不多看到了,當時為了效果好一點,還找了好多類似的來優中選優,現在終于輪到自己實作一個。其實這也是我後來一直的習慣,當看到一個好看的控件或者優秀的架構時,不光是會拿來用,還要分析别人的思路,然後看看自己能不能實作,這樣出了問題,可能更容易改。慢慢的最後就會變成接到一個新需求時,不再是去網上找第三方庫,而是自己去實作。

7 完整代碼

package com.qinshou.switchbuttondemo;

import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.View;

/**
 * Author: QinHao
 * Email:[email protected]
 * Date: 2019/6/1 16:56
 * Description:仿 iOS 風格的開關按鈕
 */
public class SwitchButton extends android.support.v7.widget.AppCompatCheckBox {
    private static final String TAG = "SwitchButton";
    /**
     * 控件預設寬度
     */
    private static final int DEFAULT_WIDTH = 200;
    /**
     * 控件預設高度
     */
    private static final int DEFAULT_HEIGHT = DEFAULT_WIDTH / 8 * 5;
    /**
     * 畫筆
     */
    private Paint mPaint;
    /**
     * 控件背景的矩形範圍
     */
    private RectF mRectF;
    /**
     * 開關訓示器按鈕圓心 X 坐标的偏移量
     */
    private float mButtonCenterXOffset;
    /**
     * 顔色漸變系數
     */
    private float mColorGradientFactor = 1;
    /**
     * 狀态切換時的動畫時長
     */
    private long mAnimateDuration = 300L;
    /**
     * 開關未選中狀态,即關閉狀态時的背景顔色
     */
    private int mBackgroundColorUnchecked = 0xFFCCCCCC;
    /**
     * 開關選中狀态,即打開狀态時的背景顔色
     */
    private int mBackgroundColorChecked = 0xFF6495ED;
    /**
     * 開關訓示器按鈕的顔色
     */
    private int mButtonColor = 0xFFFFFFFF;

    public SwitchButton(Context context) {
        this(context, null);
    }

    public SwitchButton(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public SwitchButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 不顯示 CheckBox 預設的 Button
        setButtonDrawable(null);
        // 不顯示 CheckBox 預設的背景
        setBackgroundResource(0);
        // 預設 CheckBox 為關閉狀态
        setChecked(false);

        mPaint = new Paint();
        mPaint.setAntiAlias(true);

        mRectF = new RectF();
        // 點選時開始動畫
        setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                startAnimate();
            }
        });
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int width;
        int height;
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            width = (getPaddingLeft() + DEFAULT_WIDTH + getPaddingRight());
        }

        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            height = (getPaddingTop() + DEFAULT_HEIGHT + getPaddingBottom());
        }
        setMeasuredDimension(width, height);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 設定畫筆寬度為控件寬度的 1/40,準備繪制控件背景
        mPaint.setStrokeWidth((float) getMeasuredWidth() / 40);
        // 根據是否選中的狀态設定畫筆顔色
        if (isChecked()) {
            // 選中狀态時,背景顔色由未選中狀态的背景顔色逐漸過渡到選中狀态的背景顔色
            mPaint.setColor(getCurrentColor(mColorGradientFactor, mBackgroundColorUnchecked, mBackgroundColorChecked));
        } else {
            // 未選中狀态時,背景顔色由選中狀态的背景顔色逐漸過渡到未選中狀态的背景顔色
            mPaint.setColor(getCurrentColor(mColorGradientFactor, mBackgroundColorChecked, mBackgroundColorUnchecked));
        }
        // 設定背景的矩形範圍
        mRectF.set(mPaint.getStrokeWidth()
                , mPaint.getStrokeWidth()
                , getMeasuredWidth() - mPaint.getStrokeWidth()
                , getMeasuredHeight() - mPaint.getStrokeWidth());
        // 繪制圓角矩形作為背景
        canvas.drawRoundRect(mRectF, getMeasuredHeight(), getMeasuredHeight(), mPaint);

        // 設定畫筆顔色,準備繪制開關按鈕訓示器
        mPaint.setColor(mButtonColor);
        /*
         * 擷取開關按鈕訓示器的半徑
         * 為了好看一點,開關按鈕訓示器在背景矩形中顯示一點内邊距,是以多減去兩個畫筆寬度
         */
        float radius = (getMeasuredHeight() - mPaint.getStrokeWidth() * 4) / 2;
        float x;
        float y;
        // 根據是否選中的狀态來決定開關按鈕訓示器圓心的 X 坐标
        if (isChecked()) {
//            // 選中狀态時開關按鈕訓示器在右邊
//            x = getMeasuredWidth() - radius - mPaint.getStrokeWidth() - mPaint.getStrokeWidth();
            // 選中狀态時開關按鈕訓示器圓心的 X 坐标從左邊逐漸移到右邊
            x = getMeasuredWidth() - radius - mPaint.getStrokeWidth() - mPaint.getStrokeWidth() - mButtonCenterXOffset;
        } else {
//            // 未選中狀态時開關按鈕訓示器在左邊
//            x = radius + mPaint.getStrokeWidth() + mPaint.getStrokeWidth();
            // 未選中狀态時開關按鈕訓示器圓心的 X 坐标從右邊逐漸移到左邊
            x = radius + mPaint.getStrokeWidth() + mPaint.getStrokeWidth() + mButtonCenterXOffset;
        }
        // Y 坐标就是控件高度的一半不變
        y = (float) getMeasuredHeight() / 2;
        canvas.drawCircle(x, y, radius, mPaint);
    }

    /**
     * Author: QinHao
     * Email:[email protected]
     * Date:2019/6/3 9:45
     * Description:開始開關按鈕切換狀态和背景顔色過渡的動畫
     */
    private void startAnimate() {
        // 計算開關訓示器的半徑
        float radius = (getMeasuredHeight() - mPaint.getStrokeWidth() * 4) / 2;
        // 計算開關訓示器的 X 坐标的總偏移量
        float centerXOffset = getMeasuredWidth() - mPaint.getStrokeWidth() - mPaint.getStrokeWidth() - radius
                - (mPaint.getStrokeWidth() + mPaint.getStrokeWidth() + radius);

        AnimatorSet animatorSet = new AnimatorSet();
        // 偏移量逐漸變化到 0
        ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(this, "buttonCenterXOffset", centerXOffset, 0);
        objectAnimator.setDuration(mAnimateDuration);
        objectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                invalidate();
            }
        });

        // 背景顔色過渡系數逐漸變化到 1
        ObjectAnimator objectAnimator2 = ObjectAnimator.ofFloat(this, "colorGradientFactor", 0, 1);
        objectAnimator2.setDuration(mAnimateDuration);

        // 同時開始修改開關訓示器 X 坐标偏移量的動畫和修改背景顔色過渡系數的動畫
        animatorSet.play(objectAnimator).with(objectAnimator2);
        animatorSet.start();
    }

    /**
     * Author: QinHao
     * Email:[email protected]
     * Date:2019/6/3 9:04
     * Description:擷取一個過渡期中目前顔色,fraction 為過渡系數,取值範圍 0f-1f,值越接近 1,顔色就越接近 endColor
     *
     * @param fraction   目前漸變系數
     * @param startColor 過渡開始顔色
     * @param endColor   過渡結束顔色
     * @return 目前顔色
     */
    private int getCurrentColor(float fraction, int startColor, int endColor) {
        int redStart = Color.red(startColor);
        int blueStart = Color.blue(startColor);
        int greenStart = Color.green(startColor);
        int alphaStart = Color.alpha(startColor);

        int redEnd = Color.red(endColor);
        int blueEnd = Color.blue(endColor);
        int greenEnd = Color.green(endColor);
        int alphaEnd = Color.alpha(endColor);

        int redDifference = redEnd - redStart;
        int blueDifference = blueEnd - blueStart;
        int greenDifference = greenEnd - greenStart;
        int alphaDifference = alphaEnd - alphaStart;

        int redCurrent = (int) (redStart + fraction * redDifference);
        int blueCurrent = (int) (blueStart + fraction * blueDifference);
        int greenCurrent = (int) (greenStart + fraction * greenDifference);
        int alphaCurrent = (int) (alphaStart + fraction * alphaDifference);

        return Color.argb(alphaCurrent, redCurrent, greenCurrent, blueCurrent);
    }

    public void setButtonCenterXOffset(float buttonCenterXOffset) {
        mButtonCenterXOffset = buttonCenterXOffset;
    }

    public void setColorGradientFactor(float colorGradientFactor) {
        mColorGradientFactor = colorGradientFactor;
    }

    public void setAnimateDuration(long animateDuration) {
        mAnimateDuration = animateDuration;
    }

    public void setBackgroundColorUnchecked(int backgroundColorUnchecked) {
        mBackgroundColorUnchecked = backgroundColorUnchecked;
    }

    public void setBackgroundColorChecked(int backgroundColorChecked) {
        mBackgroundColorChecked = backgroundColorChecked;
    }

    public void setButtonColor(int buttonColor) {
        mButtonColor = buttonColor;
    }
}
           

繼續閱讀