天天看点

自定义View-SwitchButton

自定义View-SwitchButton

        • 一、 分析View
        • 二、最简单的实现
        • 三、实现`Switch`功能
        • 四、测量View
        • 五、`SwitchButton`状态的保存和恢复
        • 六、给`SwitchButton`添加状态监听
        • 七、自定义属性
记得第一次写自定义View的时候,写就是switchbutton,现在回想起当时码的,感觉真的是很low爆了,前段时间业务要求需要用,虽然现在android系统提供了SwitchButton,但是还是想自己写一个来场回忆杀,哈哈!下面来看一下效果图
自定义View-SwitchButton

一、 分析View

如图

自定义View-SwitchButton
  • 由内圆和一个两边为半圆(以下都简称为外圆)的长方形背景;

    1. 我们定义内圆半径

    r

    为外圆半径

    R

    0.9

    倍:
    /**
         * 外圆半径
         */
        private float outerCircleRadio;
        
        /**
         * 内圆半径
         */
        private float innerCircleRadio;
               
  1. 为了更加美观,定义

    W=2.5*H

    R=0.5*H

    ,我们定义

    RectF

    来存储

    W、H

    /**
      * 背景绘制的区域
      */
     private RectF mBackgroundRectF;
               
  • 状态分为开关:内圆在左侧为关、内圆在右侧为开;

    1. 定义标志位

    mSwitchState

    来存储开关的状态
    /**
     * switch开关-关闭状态
     */
    private final int STATE_CLOSE = 0x8;
    
    /**
     * switch开关-打开状态
     */
    private final int STATE_OPEN = 0x10;
    
    /**
     * switch开关-状态标志位
     */
    private final int STATE_MASK = 0x18;
    
    /**
     * switch开关-默认状态
     */
    private int mSwitchState;
    
               
  • 为了区别开关状态,在每个状态分别在对应的状态给对应的内圆和背景不同的颜色来以区分;
  1. 定义

    SwitcButton

    对应状态内圆的颜色:
    /**
         * 内圆未选中时的颜色
         */
        @ColorInt
        private int mInnerCircleOpenColor;
    
        /**
         * 内圆选中时的颜色
         */
        @ColorInt
        private int mInnerCircleCloseColor;
               
  2. 定义

    SwitcButton

    对应状态背景的颜色:
    /**
         * 背景区域选中颜色
         */
        @ColorInt
        private int mBackgroundOpenColor;
    
        /**
         * 背景区域未选中颜色
         */
        @ColorInt
        private int mBackgroundCloseColor;
               

二、最简单的实现

按照上面的分析我们先不管其他因素,先按照最简单的来实现
  1. 创建类

    SwitchButton

    如下:
    public class SwitchButton extends View {
    
        public SwitchButton(Context context) {
            this(context, null);
        }
    
        public SwitchButton(Context context, @Nullable AttributeSet attrs) {
            this(context, attrs, 0);
        }
    
        public SwitchButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
        }
     
    }
               
  2. 定义上面需要的属性:
    public class SwitchButton extends View {
    
        /**
         * 内圆半径
         */
        private float innerCircleRadio;
    
        /**
         * 外圆半径
         */
        private float outerCircleRadio;
    
        /**
         * 内圆未选中时的颜色
         */
        @ColorInt
        private int mInnerCircleOpenColor;
    
        /**
         * 内圆选中时的颜色
         */
        @ColorInt
        private int mInnerCircleCloseColor;
    
    
        /**
         * 背景区域选中颜色
         */
        @ColorInt
        private int mBackgroundOpenColor;
    
        /**
         * 背景区域未选中颜色
         */
        @ColorInt
        private int mBackgroundCloseColor;
    
        /**
         * 背景绘制的区域
         */
        private RectF mBackgroundRectF;
    
    
        /**
         * switch开关-关闭状态
         */
        private final int STATE_CLOSE = 0x8;
    
        /**
         * switch开关-打开状态
         */
        private final int STATE_OPEN = 0x10;
    
        /**
         * switch开关-状态标志位
         */
        private final int STATE_MASK = 0x18;
    
    
        /**
         * switch开关-默认状态
         */
        private int mSwitchState;
    
    
        public SwitchButton(Context context) {
            this(context, null);
        }
    
        public SwitchButton(Context context, @Nullable AttributeSet attrs) {
            this(context, attrs, 0);
    
        }
    
        public SwitchButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
    
        }
    
    }
    
               
  3. 定义方法

    private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr)

    来初始化我们View的一些属性和工具:
    private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            //初始化switch状态,默认状态为关闭状态
            mSwitchState =STATE_MASK & STATE_CLOSE;
            //绘制画笔
            mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
            mPaint.setAntiAlias(true);
             //背景和内圆变化的颜色
            mBackgroundCloseColor =Color.GRAY;
            mBackgroundOpenColor =Color.WHITE;
            mInnerCircleCloseColor = Color.WHITE;
            mInnerCircleOpenColor = Color.GRAY;
    
            //背景绘制区域
            mBackgroundRectF = new RectF();
        }
    
               
  4. 重写方法

    protected void onSizeChanged(int w, int h, int oldw, int oldh)

    方法:

    1)定义属性

    mWidth

    mHeight

    来存储View的宽高,且在方法

    onSizeChanged

    中进行赋值

    mWidth=w、mHeight=h

    (当然你也可以省略这一步骤,后面通过

    getWidth和getHeight

    来获取View的宽高)
    /**
     * view 布局的宽高
     */
    private int mWidth, mHeight;
               
    2)根据上面分析,考虑到View可能设置padding值,所以我们在计算背景的高度时要减去对应padding值则

    H=mHeight-getPaddingTop()-getPaddingBottom()

    ,而我们的外圆半径为背景高度的一半且背景的宽是高的2.5倍,所以我们可以计算出来外圆的半径为

    outerCircleRadio = (w - getPaddingLeft() - getPaddingRight()) / 5f;

    所以内圆的半径为:

    innerCircleRadio = outerCircleRadio * 0.9f;

    ,背景的绘制区域为:

    mBackgroundRectF.set(getPaddingLeft(), mHeight / 2f - outerCircleRadio, mWidth - getPaddingRight(), mHeight / 2f + outerCircleRadio);

    具体代码如下:
    @Override
        protected void onSizeChanged(int w, int h, int oldw, int oldh) {
            super.onSizeChanged(w, h, oldw, oldh);
            //获取view的宽高
            mWidth = w;
            mHeight = h;
            //计算outerCircle的半径
            outerCircleRadio = (w - getPaddingLeft() - getPaddingRight()) / 5f;
            innerCircleRadio = outerCircleRadio * 0.9f;
    
            // 计算绘制背景区域, 根据背景的宽高比例为2.5f 来计算
            mBackgroundRectF.set(getPaddingLeft(), mHeight / 2f - outerCircleRadio, mWidth - getPaddingRight(), mHeight / 2f + outerCircleRadio);
        }
    
               
  5. 重写方法

    protected void onDraw(Canvas canvas)

    进行绘制:

    1)首先绘制默认关闭状态下背景,首先给画笔设置未打开时的背景颜色,然后绘制背景

    mBackgroundRectF

    mPaint.setColor(mBackgroundCloseColor);
         canvas.drawRoundRect(mBackgroundRectF, outerCircleRadio, outerCircleRadio, mPaint);
               
    2)绘制默认关闭状态下内圆,由于在绘制内圆时需要确定圆心的坐标,在此我们定义两个变量分别存储内圆圆心的坐标为

    innerCircleOx, innerCircleOy

    ,在方法

    onSizeChanged

    中初始化为

    innerCircleOx = outerCircleRadio+getPaddingLeft(), innerCircleOy = h >> 1;

    ,然后设置画笔的颜色为关闭时内圆的颜色,然后绘制内圆为:
    mPaint.setColor(innerCircleColor);
       canvas.drawCircle(innerCircleOx, innerCircleOy, innerCircleRadio, mPaint);
               
  6. 我们最初的view已经绘制完成了,但是还无法到达我们

    switch

    的效果,我们先来运行一下看看目前达到的效果,鼓励一下自己,

    1) 布局文件如下(不包含padding):

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
        <vip.zhuhailong.blogapplication.SwitchButton
            android:id="@+id/switchButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true" />
    
    </RelativeLayout>
               
    然后运行为:
    自定义View-SwitchButton
    2)我们给View添加padding值为50dp在运行如下:
    自定义View-SwitchButton
看来我们模型大致完成了,接下来就是完成

swtich

功能

三、实现

Switch

功能

有两种方式可以改变

SwtichButton

的状态:

第一种:通过点击操作来切换更改SwitchButton的操作

第二种:通过滑动内圆来更改

SwtichButton

的状态

因此我们要在处理ACTION_UP和ACTION_CANCEL事件中对当前一系列的事件进行判断,判断是否仅仅是单击事件还是中间出现了对应的业务滑动,如果仅仅是单击事件则进行状态的转换且更新内颜色位置和背景的颜色,若是滑动事件我们还要进行另一个判断,判断我们当前系列事件的down事件落点是否在当前状态下内圆内,是我们则将更新滑动的进度且补全剩下状态改变的过渡过程,

  1. 由上分析可知我们需要定义两个标志位分别来标记当前一系列事件是否是含有滑动事件和当前一系列事件的down事件的落点是否在内圆内(仅在发生滑动时间时起到作用)定义变量如下,且我们要在

    init

    方法中修改

    mSwitchState

    的初始化值为

    //初始化switch状态 mSwitchState = (STATE_MASK & STATE_CLOSE) | (EVENT_MASK & MOVE_EVENT) | (LOCATION_MASK & OUTER_LOCATION);

    /**
         * switch开关- 事件最开始落点圈外
         */
        private final int OUTER_LOCATION = 0x0;
    
        /**
         * switch开关- 事件最开始落点圈内
         */
        private final int INNER_LOCATION = 0x1;
    
        /**
         * switch开关-事件最开始落点标志位
         */
        private final int LOCATION_MASK = 0x1;
    
        /**
         * switch开关-非移动事件
         */
        private final int OTHER_EVENT = 0x2;
    
        /**
         * switch开关-移动事件
         */
        private final int MOVE_EVENT = 0x4;
    
    
        /**
         * switch开关-是否处理事件标志位
         */
        private final int EVENT_MASK = 0x6;
               
  2. 为了让我们

    SwitchButton

    状态切换的更加优雅而不是一瞬间完成那么生硬, 所以我们要创建属性动画

    mValueAnimator

    来美观且完成

    ACTION_UP和ACTION_CANCEL

    中未完成的过渡过程,因为在涉及到滑动的系列事件中每次

    ACTION_UP和ACTION_CANCEL

    X

    坐标是不确定的,所以我们只好在每次完成剩余过渡过程中动态的设定起始和结束值,所以我们在

    init

    方法中添加属性动画的最基本的且通用的初始化操作,然后创建方法

    private void startSwitchAnimation(float animatorStartX, float animatorEndX)

    l来动态的执行我们的过渡补全工作,而在配置过程中,由于动画的起始和结束是不确定的,也就是执行的过程长度是不确定的,所以我们需要进行动态的计算,我们假设从临界值到另一个临界值事件为

    1000ms

    ,我们再由实际要执行的动画长度比上我们两个临界值的差值绝对值在乘上我们

    1000ms

    ,就可以动态计算出对应的动画执行世间了,具体代码如下:
    private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    		//省略不在展示其他初始化代码
    		//恢复动画
            mValueAnimator = new ValueAnimator();
            mValueAnimator.addUpdateListener(animation -> {
                innerCircleOx = (float) animation.getAnimatedValue();
                invalidate();
            });
            mValueAnimator.setInterpolator(new BounceInterpolator());
    }
    
    private void startSwitchAnimation(float animatorStartX, float animatorEndX) {
        mValueAnimator.setFloatValues(animatorStartX, animatorEndX);
        //动态计算动画完成时间
        mValueAnimator.setDuration((long) ((Math.abs(animatorEndX - animatorStartX) * 1L / ((mWidth - getPaddingLeft() - getPaddingRight() / 2L))) * mAnimationDuration));
        mValueAnimator.start();
    }
    
               
  3. 为了更直观的可读性,我们创建修改标志位的方法和一些读取对应标志位的方法,并且将其装换位对应

    布尔值

    1)创建修改标志位方法

    onlySetFlag(int flag, int mask)

    /**
         * 设置当前的Flag
         *
         * @param flag 需要设置的值
         * @param mask 对应标志位
         */
        private void onlySetFlag(int flag, int mask) {
            mSwitchState = (mSwitchState & ~mask) | (flag & mask);
        }
               
    2)创建从mSwitchState取出switch开关的状态,判断是否为打开状态的方法

    public boolean stateIsOpen()

    /**
         * 从mSwitchState取出switch开关的状态,判断是否为打开状态
         *
         * @return 否为打开状态
         */
        public boolean stateIsOpen() {
            return (mSwitchState & STATE_MASK) == STATE_OPEN;
        }
               
    3)创建从mSwitchState取出event_mask位值,判断当前是否为业务滑动事件的方法public boolean judgeIsMoveEvent()`:
    /**
         * 从mSwitchState取出event_mask位值,判断当前是否为业务滑动事件
         *
         * @return 是否为业务滑动事件
         */
        public boolean judgeIsMoveEvent() {
            return (mSwitchState & EVENT_MASK) == MOVE_EVENT;
        }
               
    4)创建从mSwitchState取出系列事件中

    ACTION_DOWN

    事件落点坐标是否在内圆中的方法:
    /**
         * 从mSwitchState取出系列事件最开始事件落点坐标,判断是否在内圆中
         *
         * @return 是否在内圆中
         */
        public boolean locationIsInner() {
            return (mSwitchState & LOCATION_MASK) == INNER_LOCATION;
        }
               
  4. 在使用非点击事件来改变

    SwitchButton

    状态时即使用滑动,有可能出现我们的手指滑出背景外去,为了保证我们内圆在背景内,所以我们

    innerCircleOx

    定义一个范围:

    [innerCircleMaxRightX ,innerCircleMaxLeftX]

    ,如下:
    private float  innerCircleMaxLeftX, innerCircleMaxRightX;
        
    	@Override
        protected void onSizeChanged(int w, int h, int oldw, int oldh) {
            super.onSizeChanged(w, h, oldw, oldh);
    		//省略
    		
    		//初始化innerCircleOx的范围
            innerCircleMaxLeftX = mBackgroundRectLeft + outerCircleRadio;
            innerCircleMaxRightX = mBackgroundRectRight - outerCircleRadio;
            
         	//省略
        }
    
               
  5. 分析

    ACTION_DOWN

    事件

    1)首先判断我们是否要消费当前一系列事件,判断依据是当前空间是否可用

    enable

    、是否可点击

    clickable

    、是否在我们背景内

    mBackgroundRectF.contains(x, y)

    且当前过渡补充动画不在执行且没开始

    !mValueAnimator.isRunning() && !mValueAnimator.isStarted()

    即:
    @Override
    public boolean onTouchEvent(MotionEvent event) {
    	float x = event.getX();
        float y = event.getY();	
    	if (event.getAction() == MotionEvent.ACTION_DOWN) {
           //判断当前是否可用、可点击、在点击范围内且不再执行恢复动画
           return isEnabled() && isClickable() && mBackgroundRectF.contains(x, y) && !mValueAnimator.isRunning() && !mValueAnimator.isStarted();
        }
    }
               
    2)判断当前的落点是否在当前内圆范围内,并更新标志位,计算方法为:用当前事件的坐标和内圆圆心的坐标进行距离求值,若小于等于外圆半径则为园内,否则圆外:

    boolean insideInnerCircle = Math.sqrt(Math.pow(Math.abs(motionEventStartX - innerCircleOx), 2) + Math.pow(Math.abs(event.getY() - innerCircleOy), 2)) <= outerCircleRadio;

    ,然后更新对应的标记位

    onlySetFlag(insideInnerCircle ? INNER_LOCATION : OUTER_LOCATION, LOCATION_MASK);

    若后期产生了移动事件则用作为是否处理当前系列事件的依据,所以完整的

    ACTION_DOWN

    事件处理方案为:
    float x = event.getX();
    float y = event.getY();
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
             //判断系列事件最开始事件落点坐标是否在内圆中
             boolean insideInnerCircle = Math.sqrt(Math.pow(Math.abs(x- innerCircleOx), 2) + Math.pow(Math.abs(event.getY() - innerCircleOy), 2)) <= outerCircleRadio;
             //上面判断值存储到mSwitchState中
             onlySetFlag(insideInnerCircle ? INNER_LOCATION : OUTER_LOCATION, LOCATION_MASK);
             //判断当前是否可用、可点击、在点击范围内且不再执行恢复动画
             return isEnabled() && isClickable() && mBackgroundRectF.contains(x, y) && !mValueAnimator.isRunning() && !mValueAnimator.isStarted();
         }
    }
               
  6. 分析

    ACTION_MOVE

    事件

    1)首先判断当前的

    ACTION_MOVE

    事件对应标记位是否已经更新

    !judgeIsMoveEvent()

    ,若为更新则更新标记位

    onlySetFlag(MOVE_EVENT, EVENT_MASK);

    //取出对应标记值判断当前是否处于滑动状态,若不处于滑动状态,则更新EVENT_MASK(事件标记位)为对应的值
    if (!judgeIsMoveEvent()) {
         onlySetFlag(MOVE_EVENT, EVENT_MASK);
     }
               
    2)按照业务逻辑,由于我们滑动内圆,我们应该更新对应内圆的位置,但是对应的若

    ACTION_DOWN

    落点不在我们当时内圆的范围内,此时我们的

    ACTION_MOVE

    将不处理此系列事件直接抛弃,若在园内我们将更新移动的位置,且保证内圆位置在上面限定的范围内即背景范围内,结合更新

    事件标记位

    代码如下:
    if (event.getAction() == MotionEvent.ACTION_MOVE) {
                //取出对应标记值判断当前是否处于滑动状态,若不处于滑动状态,则更新EVENT_MASK(事件标记位)为对应的值
                if (!judgeIsMoveEvent()) {
                    onlySetFlag(MOVE_EVENT, EVENT_MASK);
                }
                //判断当前是否处于业务滑动事件且系列事件最开始事件起始落点在最初状态的内圆内(实际按照外圆半径计算)
                //true 更新内圆innerCircleOx值,更新界面
                //false 不处理
                if (judgeIsMoveEvent() && locationIsInner()) {
                    innerCircleOx = x <= innerCircleMaxLeftX ? innerCircleMaxLeftX : x >= innerCircleMaxRightX ? innerCircleMaxRightX : x;
                    invalidate();
    }
               
  7. 事件结束处理

    ACTION_UP

    ACTION_MOVE

    事件
    由于

    SwitchButton

    状态的改变牵扯到两种方式,所以我们要分类处理不同的方式,通过方法

    judgeIsMoveEvent()

    取出对应的标志位判断是否出现滑动事件来判断是点击模式还是滑动模式
    1)

    judgeIsMoveEvent()=true

    即滑动模式:首先把对应的事件标记位重置(为了避免后面忘记重置事件标记位)

    onlySetFlag(OTHER_EVENT, EVENT_MASK);

    ,接着要判断

    ACTION_DOWN

    事件落点是否在当时的内圆内,若不在则不作任何处理,若在则更新当前

    innerCircleOx = x <= innerCircleMaxLeftX ? innerCircleMaxLeftX : x >= innerCircleMaxRightX ? innerCircleMaxRightX : x;

    判断当前事件的

    X

    轴坐标是否在对应移动范围的临界值,在根据当前

    innerCircleOx

    是否过移动范围的一半对应的

    X轴坐标

    来判断当前

    SwitchButton

    所处的状态

    boolean stateIsOpen = innerCircleOx > (innerCircleMaxRightX + innerCircleMaxLeftX) / 2f;

    然后更新

    mSwitchState

    对应的标记位

    onlySetFlag(stateIsOpen ? STATE_OPEN : STATE_CLOSE, STATE_MASK);

    ,接着我们配置我们的动画来补充未完成的滑动过程,由于我们此时事件的

    X

    轴坐标可能恰好在移动范围的临界值上,这样我们就没必要在配置动画进行状态过程补充了这样也能避免一些不必要的资源浪费,即:
    if (innerCircleOx == innerCircleMaxLeftX || innerCircleOx == innerCircleMaxRightX) {
         return true;
     }
               
    若在临界范围之间则调用方法

    startSwitchAnimation

    来补充未完成的进度即:
    animatorStartX = innerCircleOx;
     animatorEndX = stateIsOpen ? innerCircleMaxRightX : innerCircleMaxLeftX;
     startSwitchAnimation(animatorStartX, animatorEndX);
               
    2)

    judgeIsMoveEvent()=false

    即点击模式:这个相对好处理点,直接从对应临界值到另一个临界值的过程即:
    //到此为点击事件,直接修改switchState值,且开始过度动画从老值过渡到最新值
      animatorStartX = innerCircleOx;
      animatorEndX = stateIsOpen() ? innerCircleMaxLeftX : innerCircleMaxRightX;
      onlySetFlag(stateIsOpen() ? STATE_CLOSE : STATE_OPEN, STATE_MASK);
      startSwitchAnimation(animatorStartX, animatorEndX);
               
    运行看一下我们的效果:
    自定义View-SwitchButton
    从效果来看我们大致实现我们的功能,但是不够美观我们之前的状态颜色也没有用上,所以我们需让颜色对号入座
  8. 为了使我们的

    SwitchButton

    更加炫酷,我们使内圆和背景的颜色随内圆运动在两种状态颜色中过渡呈现,我们要根据内圆圆心的

    X轴坐标

    在其移动范围的进度值来动态计算对应的颜色。

    1)首先我们在方法

    onDraw()

    中计算进度值:

    float percent = (innerCircleOx - innerCircleMaxLeftX) / (innerCircleMaxRightX - innerCircleMaxLeftX);

    2)创建类

    ArgbEvaluator mArgbEvaluator;

    用来在方法

    onDraw()

    计算所处进度对应的颜色值:
    //当前background的color
    int backgroundColor = (int) mArgbEvaluator.evaluate(percent, mBackgroundCloseColor, mBackgroundOpenColor);
    //内圆当前的颜色
    int innerCircleColor = (int) mArgbEvaluator.evaluate(percent, mInnerCircleCloseColor, mInnerCircleOpenColor);
               
    3)所以更新

    onDraw()

    方法代码为:
    @Override
        protected void onDraw(Canvas canvas) {
            //当前内圆圆心所在位置对应的进度
            float percent = (innerCircleOx - innerCircleMaxLeftX) / (innerCircleMaxRightX - innerCircleMaxLeftX);
            //当前background的color
            int backgroundColor = (int) mArgbEvaluator.evaluate(percent, mBackgroundCloseColor, mBackgroundOpenColor);
            //内圆当前的颜色
            int innerCircleColor = (int) mArgbEvaluator.evaluate(percent, mInnerCircleCloseColor, mInnerCircleOpenColor);
    
            mPaint.setColor(backgroundColor);
            canvas.drawRoundRect(mBackgroundRectF, outerCircleRadio, outerCircleRadio, mPaint);
    
            mPaint.setColor(innerCircleColor);
            canvas.drawCircle(innerCircleOx, innerCircleOy, innerCircleRadio, mPaint);
        }
               
    运行效果为:
    自定义View-SwitchButton
    从效果上来我们对颜色的渐变已经达到了我们的效果,nice!

四、测量View

因为自己每次自定义View时,都会非常纠结这部分,因为涉及到测量模式

wrap_content

,需要自己业务的需要和实际考虑来返回View的测量宽高
  1. 未重写方法

    onMeasure

    方法出现的问题,举个例子我们给

    SwitchButton

    宽度固定值

    100dp

    ,高度为自适应

    warp_content

    ,

    padding

    值为

    50dp

    ,为了更好显示突出我们的视觉逻辑效果,我们给控件一个背景色为

    #32cd32

    布局文件如下:
    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
        <vip.zhuhailong.blogapplication.SwitchButton
            android:id="@+id/switchButton"
            android:layout_width="100dp"
            android:padding="50dp"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true" />
    
    </RelativeLayout>
               
    运行看一下效果:
    自定义View-SwitchButton

    what ?SwitcButton呢?

    由图中可知道我们View在实实在在的存在的,但是我们的

    SwitchButton

    却不见了,这是由于我们的实际绘制区域宽度变为了 ,我们在设置宽度为

    100dp

    ,而

    padding

    值为

    50dp

    ,我们在计算外圆半径的计算方式为

    (w - getPaddingLeft() - getPaddingRight()) / 5f

    ,也就是

    100dp-50dp-50dp=0dp

    也就是宽度为 了自然也就没有空白了,当然只是我们需要自己重写

    onMeasure

    方法的特殊情况,所以我们修改

    padding

    值为

    10dp

    在重新运行一下为:
    自定义View-SwitchButton
    这样我们就要显示出来,但是却为突出不重写

    onMeasure

    方法会出现的问题,从图中的效果来看(不从源码分析,网上有很多关于源码的分析当家可以自己去查看),在未重写

    onMeasure

    方法时,系统

    warp_content

    测量值和

    match_parent

    测量值一样的,现在我们修改控件的宽为

    warp_content

    ,高度为固定值为

    100dp

    ,

    padding

    值为

    10dp

    ,布局文件如下:
    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
        <vip.zhuhailong.blogapplication.SwitchButton
            android:id="@+id/switchButton"
            android:layout_width="wrap_content"
            android:padding="10dp"
            android:background="#32cd32"
            android:layout_height="100dp"
            android:layout_centerInParent="true" />
    
    </RelativeLayout>
               
    运行效果如图:
    自定义View-SwitchButton

    surprise!

    这样我们的问题就凸显出来了,因为在规定

    SwitchButton的背景宽度

    是其

    高度的2.5倍

    ,而我们的

    SwitchButton的宽度

    w - getPaddingLeft() - getPaddingRight()

    ,那么

    SwitchButton的高度为:(w - getPaddingLeft() - getPaddingRight())*2/5f

    ,而之前我们设置了控件的

    高度

    为固定值

    100dp

    ,此时由于

    SwitchButton的高度

    是几乎充满水平防线的屏幕宽度的,也就是说

    SwitchButton的高度

    是有很大的可能大于

    100dp

    的,这也就造成了我们

    View

    控件提供的高度不足以绘制我们的

    SwitchButton背景

    ,也就是现在显示的残缺不全的效果,因此我们需要自己根据View的实际可绘制的宽高来配置我们

    SwitchButton

    宽高,进行绘制,所以我们就需要重写

    onMeasure

    方法。
  2. 重写

    onMeasure

    方法,关于

    onMeasure

    的一些知识这里就不在介绍了,需要了解的自行去搜索

    1)

    MeasureSpec.EXACTLY

    模式即

    match_parent或者是给定确切固定值

    ,这里比较简单,如果是这模式我们直接采用系统给我们侧脸好的值返回就行了,后面再根据实际的业务需要来计算我们对应的属性(这里没什么好说的)

    2)

    MeasureSpec.AT_MOST

    模式即

    warp_content

    ,这里是我最纠结的部分了,因为在这个模式系统会给我们提供当前

    parent

    能提供给的最大空间,我们可以选择给自己设定的最小默认值来进行绘制,也可以根据

    parent

    提供的最大空间结合业务逻辑来计算对应的属性,这里先采用给予设定的默认值来计算绘制我们的

    SwitchButton

    3)

    MeasureSpec.UNSPECIFIED

    模式,这种方式未指定尺寸,这种模式用的特别的少,这里也没什么好讲的,直接舍去,下面就开始就行我们实际编码

    onMeasure

    方法
  3. 根据我们的业务逻辑重写

    onMeasure

    方法来测量我们的空间,从而进行

    SwitchButton

    的绘制

    1)设置

    SwitchButton

    绘制区域的最小宽度,定义属性

    int defaultMinDimension = 50

    也就是最小宽度,根据最前面的逻辑(

    SwitchButton的宽是高的2.5倍

    )来计算

    SwitchButton

    的高度即

    defaultMinDimension/2.5f

    2)在

    init()

    方法中先将最小的默认值赋予我们存储空间宽高的属性用于onMeasure方法来进行测量
    mWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, defaultMinDimension, getResources().getDisplayMetrics());
    mHeight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, defaultMinDimension / 2.5f, getResources().getDisplayMetrics());
               
    3)测量View的宽度:获取宽度的测量模式

    int widthMeasureMode = MeasureSpec.getMode(widthMeasureSpec);

    然后获取系统在当前测量模式提供的测量参考值

    int measureWith = MeasureSpec.getSize(widthMeasureSpec);

    ,若测量模式为

    MeasureSpec.EXACTLY

    ,则直接采用系统提供的测量参考值,若测量模式不为

    MeasureSpec.EXACTLY

    ,此时我们将

    MeasureSpec.AT_MOST和MeasureSpec.UNSPECIFIED

    并在一起处理,此时我们采用自己定义的

    默认值

    加上

    paddingLeft

    paddingRight

    为:

    measureWith = mWidth + getPaddingLeft() + getPaddingRight();

    ,此时我们不管这样计算得来的值是否超过

    parent

    给我们提供的空间,因为这是我们的底线!哈哈

    4)测量View的高度:获取高度的测量模式

    int heightMeasureMode = MeasureSpec.getMode(heightMeasureSpec);

    ,然后获取系统在当前测量模式提供的测量参考值

    int measureHeight = MeasureSpec.getSize(heightMeasureSpec);

    ,和测量宽度一样,若测量模式为:

    MeasureSpec.EXACTLY

    ,则直接采用系统提供的测量参考值,若测量模式不为

    MeasureSpec.EXACTLY

    ,此时我们将

    MeasureSpec.AT_MOST和MeasureSpec.UNSPECIFIED

    并在一起处理,此时我们采用自定义的

    默认值

    加上

    paddingTop

    paddingBottom

    为:

    measureHeight = getPaddingTop() + getPaddingBottom() + mHeight;

    5)设置我们最终View的测量结果

    setMeasuredDimension(measureWith, measureHeight)

    6)

    onMeasure

    代码为:
    @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            int widthMeasureMode = MeasureSpec.getMode(widthMeasureSpec);
            int measureWith = MeasureSpec.getSize(widthMeasureSpec);
            int heightMeasureMode = MeasureSpec.getMode(heightMeasureSpec);
            int measureHeight = MeasureSpec.getSize(heightMeasureSpec);
    
            if (widthMeasureMode != MeasureSpec.EXACTLY) {
                measureWith = mWidth + getPaddingLeft() + getPaddingRight();
            }
            if (heightMeasureMode != MeasureSpec.EXACTLY) {
                measureHeight = getPaddingTop() + getPaddingBottom() + mHeight;
            }
            setMeasuredDimension(measureWith, measureHeight);
        }
               
  4. 测量完

    View

    后并不是大功告成直接进行我们的

    layout

    onDraw

    工作,

    onMeasure

    并不是

    View

    最终的宽高,它还牵扯到它的父控件

    ViewGroup

    为它布局时实际赋予的宽高也就是我们后面使用

    View

    中方法

    getWidth()和getHeight()

    获取的值,此值不一定等于我们的测量值,但是测量值它是

    ViewGroup

    测量和布局的一个参考,因此我们还要在得到实际控件的宽高时,对绘制需要的一些属性就行初始化操作,这里我们在方法

    onSizeChanged

    中进行,因为控件的宽高发生变化时会回调这个方法,届时我们就可以再次重新初始化我们的绘制属性了在这里我们要注意的时上面演示过得问题的,就是有可能我们的绘制区域超过了我们的View范围,所以在这里我们要结合View的实际宽高来计算我们的绘制相关属性。

    1)确定我们的外圆半径,我们先按照View的宽度来计算外圆半径

    float radioByWidth = (w - getPaddingLeft() - getPaddingRight()) / 5f

    ,然后利用外圆半径计算

    SwitchButton

    绘制区域的高度加上

    paddingTop

    paddingBottom

    ,再将其和于View的高度进行比较,得到

    boolean

    型变量

    isResize = radioByWidth * 2 + getPaddingTop() + getPaddingBottom() > h;

    然后根据这个变量来重新判断是否重新根据

    View

    的高度来计算外圆半径,若

    isResize=false

    则直接采用根据

    View宽度

    计算得来的外圆半径,若

    isResize=true

    则需要根据

    View

    的高度来重新计算外圆半径即

    outerCircleRadio = isResize ? (h - getPaddingTop() - getPaddingBottom()) / 2f : radioByWidth

    2)计算我们内圆半径,这个相对要简单,因为上面已经确定了外圆半径了我们只要取外圆半径的

    0.9

    倍的值即可:

    innerCircleRadio = outerCircleRadio *0.9

    3)确定

    SwitchButton

    绘制区域,配置

    mBackgroundRectF

    ,根据

    isResize

    的值来动态计算,定义变量

    mBackgroundRectLeft和mBackgroundRectRight

    分别用来存储

    mBackgroundRectF

    left和right

    ,计算比较简单也比较好理解,这里不接讲述直接贴出:
    //计算背景mBackgroundRectF的left和right
    float mBackgroundRectLeft = isResize ? (mWidth - outerCircleRadio * 5f) / 2 : getPaddingLeft();
    float mBackgroundRectRight = isResize ? (mWidth + outerCircleRadio * 5f) / 2 : mWidth - getPaddingRight();
    // 计算绘制背景区域, 根据背景的宽高比例为2.5f 来计算
    mBackgroundRectF.set(mBackgroundRectLeft, mHeight / 2f - outerCircleRadio, mBackgroundRectRight, mHeight / 2f + outerCircleRadio);
    
               
    4)配置我们的内圆移动范围,这个也较简单好理解,所以直接贴出:
    ```
     //初始化innerCircleOx的范围
     innerCircleMaxLeftX = mBackgroundRectLeft + outerCircleRadio;
     innerCircleMaxRightX = mBackgroundRectRight - outerCircleRadio;
     ```
               
    5)根据当前

    SwitchButton

    的状态确定当前内圆的位置.
    //初始化内圆圆心坐标即内圆位置
    innerCircleOx = stateIsOpen() ? innerCircleMaxRightX : innerCircleMaxLeftX;
    innerCircleOy = h >> 1;
               
    6)完成的

    onSizeChanged

    方法:
@Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        //获取view的宽高
        mWidth = w;
        mHeight = h;
        //计算outerCircle的半径
        float radioByWidth = (mWidth - getPaddingLeft() - getPaddingRight()) / 5f;
        //根据宽高来判断radioByWidth是否符合对应的比例
        boolean isResize = radioByWidth * 2 + getPaddingTop() + getPaddingBottom() > mHeight;
        outerCircleRadio = isResize ? (mHeight - getPaddingTop() - getPaddingBottom()) / 2f : radioByWidth;
        //计算背景mBackgroundRectF的left和right
        float mBackgroundRectLeft = isResize ? (mWidth - outerCircleRadio * 5f) / 2 : getPaddingLeft();
        float mBackgroundRectRight = isResize ? (mWidth + outerCircleRadio * 5f) / 2 : mWidth - getPaddingRight();
        // 计算绘制背景区域, 根据背景的宽高比例为2.5f 来计算
        mBackgroundRectF.set(mBackgroundRectLeft, mHeight / 2f - outerCircleRadio, mBackgroundRectRight, mHeight / 2f + outerCircleRadio);
        //计算内圆半径
        innerCircleRadio = outerCircleRadio * 0.9f;
        //初始化innerCircleOx的范围
        innerCircleMaxLeftX = mBackgroundRectLeft + outerCircleRadio;
        innerCircleMaxRightX = mBackgroundRectRight - outerCircleRadio;
        //初始化内圆圆心坐标即内圆位置
        innerCircleOx = stateIsOpen() ? innerCircleMaxRightX : innerCircleMaxLeftX;
        innerCircleOy = h >> 1;

    }
           

再次运行之前测试的配置如下:

自定义View-SwitchButton

完美解决!

五、

SwitchButton

状态的保存和恢复

在日常使用app过程中手机有时难免会横放,致使屏幕旋转,而这时我们的View经历销毁然后重建,然后再回复之前的状态,系统提供的TextView等控件只要我们给其唯一的

id

,系统都能正确恢复其销毁前的状态,那我们看看我们继承View来码的

SwitchButton

在屏幕发生旋转时系统会不会为其恢复,当然我们也会给其唯一的

Id

(至于为什么需要给其唯一的ID系统才能恢复等不是本篇探讨的内容想要深入了解的可以去网上搜索相关资料或者阅读源码),测试效果图如下,先将

SwitchButton

打开然后再旋转屏幕,发现

SwitchButton

并没有保持打开的状态也就是说系统没有帮我进行数据状态的恢复,因此我们要自己手动去保存且恢复状态:
自定义View-SwitchButton

要想达到我们想要的效果需要重写两个,方法

protected Parcelable onSaveInstanceState()

用于保存空间的当前属性状态,方法

protected void onRestoreInstanceState(Parcelable state)

用于恢复空间的属性,在重新创建控件类后会调用此房方法,我们参考

TextView

的实现(大家自行去查看源码,这里不再探讨了),创建类

SaveState

继承

View

中的静态类

BaseSavedState

,这里我们需要保存当前

SwitchButton的开关状态

即:

static class SaveState extends BaseSavedState {
		//对应SwitchButton的mSwitchState属性
        private int switchState;	

        public SaveState(Parcel source) {
            super(source);
            switchState = source.readInt();
        }

        public SaveState(Parcelable superState) {
            super(superState);
        }

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeInt(switchState);
        }
        
        public static final Parcelable.Creator<SaveState> CREATOR = new Creator<SaveState>() {
            @Override
            public SaveState createFromParcel(Parcel source) {
                return new SaveState(source);
            }

            @Override
            public SaveState[] newArray(int size) {
                return new SaveState[size];
            }
        };

    }
           

1)重写方法保存属性状态的方法

protected Parcelable onSaveInstanceState()

@Nullable
    @Override
    protected Parcelable onSaveInstanceState() {
        Parcelable parcelable = super.onSaveInstanceState();
        SaveState saveState = new SaveState(parcelable);
        saveState.switchState = mSwitchState;
        return saveState;
    }
           

2)重写方法保存属性状态的方法

protected void onRestoreInstanceState(Parcelable state)

@Override
    protected void onRestoreInstanceState(Parcelable state) {
        if (state instanceof SaveState) {
            SaveState saveState = (SaveState) state;
            super.onRestoreInstanceState(saveState.getSuperState());
            mSwitchState = saveState.switchState;
        } else {
            super.onRestoreInstanceState(state);
        }

    }
           

至此我们的属性的保存及恢复就完成了,以下为测试结果,由此可见我们我们恢复效果达到了:

自定义View-SwitchButton

六、给

SwitchButton

添加状态监听

其实这个过程还是比较简单的,直接开码
  1. 定义接口

    SwitchListener

    /**
         * 回调状态
         */
        public interface SwitchListener {
            void switchListener(boolean open);
        }
    
               
  2. SwitchButton

    添加

    SwitchListener

    相关配置操作
    /**
         * switch button 状态监听
         */
        private SwitchListener mSwitchListener;
    
        public void setSwitchListener(SwitchListener switchListener) {
            mSwitchListener = switchListener;
        }
               
  3. 在合适的地方法进行回调我们的监听函数,思考一下,无非就是判断当前是否发生了

    SwitchButton

    状态的变化来回调

    1)先说发生状态改变的情况,这时无非就是

    点击

    滑动

    来更改

    SwitchButton

    的状态,所以我们只需要在执行动画的方法

    startSwitchAnimation(float animatorStartX, float animatorEndX, boolean flagIsChange)

    中进行回调就行了,而在

    滑动

    方式更改状态的情况下,滑动事件中的

    X

    的值可能为临界值,这时因为已经是当前要过渡到的效果了所以为了减少一些没必要资源开销,我们舍弃了过渡动画只要进行监听回调即可如下:
    @Override
        public boolean onTouchEvent(MotionEvent event) {
            //省略
            if (judgeIsMoveEvent()) {
               		//省略
                    onlySetFlag(stateIsOpen ? STATE_OPEN : STATE_CLOSE, STATE_MASK);
                    if (innerCircleOx == innerCircleMaxLeftX || innerCircleOx == innerCircleMaxRightX) {
                        if (mSwitchListener != null) {
                            mSwitchListener.switchListener(stateIsOpen);
                        }
                        return true;
                    }
                    animatorStartX = innerCircleOx;
                    animatorEndX = stateIsOpen ? innerCircleMaxRightX : innerCircleMaxLeftX;
                    startSwitchAnimation(animatorStartX, animatorEndX);
                }
            } else {
                //到此为点击事件,直接修改switchState值,且开始过度动画从老值过渡到最新值
                animatorStartX = innerCircleOx;
                animatorEndX = stateIsOpen() ? innerCircleMaxLeftX : innerCircleMaxRightX;
                onlySetFlag(stateIsOpen() ? STATE_CLOSE : STATE_OPEN, STATE_MASK);
                startSwitchAnimation(animatorStartX, animatorEndX);
            }
    
            //省略
        }
    
        /**
         * 设置我们的恢复动画相关的属性且开始动画
         *
         * @param animatorStartX 动画开始值
         * @param animatorEndX   动画结束值
         */
        private void startSwitchAnimation(float animatorStartX, float animatorEndX) {
            mValueAnimator.setFloatValues(animatorStartX, animatorEndX);
            //动态计算动画完成时间
            mValueAnimator.setDuration((long) ((Math.abs(animatorEndX - animatorStartX) * 1L / ((mWidth - getPaddingLeft() - getPaddingRight() / 2L))) * mAnimationDuration));
            mValueAnimator.start();
            //switchState状态监听回调
            if (mSwitchListener != null ) {
                mSwitchListener.switchListener(stateIsOpen());
            }
        }
    
               
    2)未发生状态改变的情况,大家可能疑问了,未发生肯定就不调用啊,这个自然,但是我们的回调有一处是写在执行动画的方法里的,因为当前可能未发生状态的改变但是内圆的位置却不在该状态所在的位置,所以我们还要使用动画来过渡,所以我们还要在执行动画的方法里还要判断当前是否发生的了变化,所以在方法

    startSwitchAnimation(float animatorStartX, float animatorEndX, boolean flagIsChange)

    中增加

    boolean

    型变量控制是否执行回调,为了更好判断是否发生了变化,因此我们创建方法

    boolean setFlagAndGetChangeValue(int flag, int mask)

    ,在修改标志位的同时,判断当前状态是否发生了变化,所以以上代码修改如下:
    /**
         * 废除了ClickListener和LongClickListener
         *
         * @param event 事件
         */
        @Override
        public boolean onTouchEvent(MotionEvent event) {
           		//省略
                //判断是上个事件是否为业务滑动事件
                if (judgeIsMoveEvent()) {
                    onlySetFlag(OTHER_EVENT, EVENT_MASK);
                    if (locationIsInner()) {
                        innerCircleOx = x <= innerCircleMaxLeftX ? innerCircleMaxLeftX : x >= innerCircleMaxRightX ? innerCircleMaxRightX : x;
                        boolean stateIsOpen = innerCircleOx > (innerCircleMaxRightX + innerCircleMaxLeftX) / 2f;
                        boolean changeValue = setFlagAndGetChangeValue(stateIsOpen ? STATE_OPEN : STATE_CLOSE, STATE_MASK);
                        if (innerCircleOx == innerCircleMaxLeftX || innerCircleOx == innerCircleMaxRightX) {
                            if (mSwitchListener != null && changeValue) {
                                mSwitchListener.switchListener(stateIsOpen);
                            }
                            return true;
                        }
                        animatorStartX = innerCircleOx;
                        animatorEndX = stateIsOpen ? innerCircleMaxRightX : innerCircleMaxLeftX;
                        startSwitchAnimation(animatorStartX, animatorEndX,changeValue);
                    }
                } else {
                    //到此为点击事件,直接修改switchState值,且开始过度动画从老值过渡到最新值
                    animatorStartX = innerCircleOx;
                    animatorEndX = stateIsOpen() ? innerCircleMaxLeftX : innerCircleMaxRightX;
                    onlySetFlag(stateIsOpen() ? STATE_CLOSE : STATE_OPEN, STATE_MASK);
                    startSwitchAnimation(animatorStartX, animatorEndX,true);
                }
            }
            return true;
        }
    
        /**
         * 设置我们的恢复动画相关的属性且开始动画
         *
         * @param animatorStartX 动画开始值
         * @param animatorEndX   动画结束值
         */
        private void startSwitchAnimation(float animatorStartX, float animatorEndX, boolean flagIsChange) {
            mValueAnimator.setFloatValues(animatorStartX, animatorEndX);
            //动态计算动画完成时间
            mValueAnimator.setDuration((long) ((Math.abs(animatorEndX - animatorStartX) * 1L / ((mWidth - getPaddingLeft() - getPaddingRight() / 2L))) * mAnimationDuration));
            mValueAnimator.start();
            //switchState状态监听回调
            if (mSwitchListener != null && flagIsChange) {
                mSwitchListener.switchListener(stateIsOpen());
            }
    
        }
               
    3)当然我们还需要能够在代码中控制改变

    SwitchButton

    的状态,所以我们要添加方法

    public void setSwitchState(boolean state)

    来进行直接修改

    SwitchButton

    的状态,当然,在这里我们还需要判断代码设置前后是否发生了状态的改变,来合理进行

    SwitchListener

    的回调和状态过渡,代码如下:
    /**
         * 设置我们Switch开关的状态
         *
         * @param state 要设置的switchState的值
         */
        public void setSwitchState(boolean state) {
            boolean isUsefulSetting = setFlagAndGetChangeValue(state ? STATE_OPEN : STATE_CLOSE, STATE_MASK);
            if (isUsefulSetting) {
                float animatorStartX, animatorEndX;
                animatorStartX = innerCircleOx;
                animatorEndX = stateIsOpen() ? innerCircleMaxRightX : innerCircleMaxLeftX;
                startSwitchAnimation(animatorStartX, animatorEndX,true);
            }
        }
    
        /**
         * 设置当前的Flag且返回当前是否发生了改变,
         *
         * @param flag 需要设置的值
         * @param mask 对应标志位
         * @return 是否发生了改变
         */
        private boolean setFlagAndGetChangeValue(int flag, int mask) {
            int oldState = mSwitchState;
            mSwitchState = (mSwitchState & ~mask) | (flag & mask);
            return (oldState ^ mSwitchState) > 0;
        }
    
               

    BlogActivity

    中先测试

    SwitchListener

    ,给

    SwitchButton

    设置监听事件如下:
    public class MainActivity extends AppCompatActivity {
    
        private SwitchButton mSwitchButton;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
            mSwitchButton = ((SwitchButton) findViewById(R.id.switchButton));
    
            mSwitchButton.setSwitchListener(open ->
                    Toast.makeText(MainActivity.this, "当前状态为" + (open ? "开" : "关"), Toast.LENGTH_SHORT).show()
            );
        }
    }
    
               
    为了更好显示效果,我们设置

    SwitchButton

    的宽高为

    match_parent

    ,测试结果为:
    自定义View-SwitchButton
    从测试结果来看我们的

    SwitchListener

    是没有问题的,现在我们来测试一下直接在代码中修改

    SwitchButton

    的状态,创建两个

    button

    分别在其点击事件中修改

    SwitchButton

    的状态,布局文件和代码如下:
    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
    
        <vip.zhuhailong.blogapplication.SwitchButton
            android:id="@+id/switchButton"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_centerInParent="true"
            android:background="#32cd32"
            android:padding="10dp" />
    
    
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:layout_marginTop="20dp"
            android:background="@color/colorAccent"
            android:orientation="horizontal">
    
    
            <Button
                android:id="@+id/closeBtn"
                android:layout_width="100dp"
                android:layout_height="wrap_content"
                android:layout_gravity="center_vertical"
                android:layout_marginLeft="20dp"
                android:layout_marginRight="20dp"
                android:layout_weight="1"
                android:background="@drawable/btn_background"
                android:text="关闭按钮" />
    
            <Button
                android:id="@+id/openBtn"
                android:layout_width="100dp"
                android:layout_height="wrap_content"
                android:layout_gravity="center_vertical"
                android:layout_marginLeft="20dp"
                android:layout_marginRight="20dp"
                android:layout_weight="1"
                android:background="@drawable/btn_background"
                android:text="打开按钮" />
    
        </LinearLayout>
    
    	public class MainActivity extends AppCompatActivity {
    
        private SwitchButton mSwitchButton;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
            mSwitchButton = ((SwitchButton) findViewById(R.id.switchButton));
    
            mSwitchButton.setSwitchListener(open ->
                    Toast.makeText(MainActivity.this, "当前状态为" + (open ? "开" : "关"), Toast.LENGTH_SHORT).show()
            );
    
            findViewById(R.id.openBtn).setOnClickListener(v -> mSwitchButton.setSwitchState(true));
            findViewById(R.id.closeBtn).setOnClickListener(v -> mSwitchButton.setSwitchState(false));
        }
    }
    
    
               
    运行测试结果如下:
    自定义View-SwitchButton

七、自定义属性

其实到达这一步骤,基本上我们的

SwitchButton

就完成了,但是为了使我们的控件更加好看,能够灵活配合业务UI设计,我们可以将那些固定的颜色、外圆半径和内圆半径比变为可以在

xml

中配置的自定义属性,如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>


    <declare-styleable name="SwitchButton">
        <!--配置内圆半径和外圆半径的比率,动态调整内圆大小-->
        <attr name="circleRadioScale" format="float|reference" />
        <!--内圆未选中(打开)的颜色-->
        <attr name="innerCircleOpenColor" format="color|reference" />
        <attr name="innerCircleCloseColor" format="color|reference" />
        <!--背景区域选中(打开)颜色-->
        <attr name="backgroundOpenColor" format="color|reference" />
        <!--背景区域未选中(关闭)颜色-->
        <attr name="backgroundCloseColor" format="color|reference" />
        <!--关闭到打开动画需要执行的总时长-->
        <attr name="animationDuration" format="integer" />
    </declare-styleable>

</resources>
           

因此我们需要修改方法

init()

在此获取对应的自定义属性,这里不再讲解一下贴出整个代码逻辑:

public class SwitchButton extends View {

    private final String TAG = this.getClass().getSimpleName();


    /**
     * 绘制画笔
     */
    private Paint mPaint;

    /**
     * 内圆半径
     */
    private float innerCircleRadio;

    /**
     * 内圆圆心坐标
     */
    private float innerCircleOx, innerCircleOy, innerCircleMaxLeftX, innerCircleMaxRightX;

    /**
     * 内圆未选中时的颜色
     */
    @ColorInt
    private int mInnerCircleOpenColor;

    /**
     * 内圆选中时的颜色
     */
    @ColorInt
    private int mInnerCircleCloseColor;


    /**
     * 外圆半径
     */
    private float outerCircleRadio;

    /**
     * 背景区域选中颜色
     */
    @ColorInt
    private int mBackgroundOpenColor;

    /**
     * 背景区域未选中颜色
     */
    @ColorInt
    private int mBackgroundCloseColor;

    /**
     * view 布局的宽高
     */
    private int mWidth, mHeight;

    /**
     * 背景绘制的区域
     */
    private RectF mBackgroundRectF;

    /**
     * 内圆半径和外圆半径比
     */
    private float circleRadioScale;

    /**
     * 默认switch按钮最小的background宽度
     */
    @Dimension
    private int defaultMinDimension = 50;

    /**
     * 恢复动画
     */
    private ValueAnimator mValueAnimator;

    /**
     * 动画执行时长
     */
    private long mAnimationDuration;


    /**
     * switch开关- 事件最开始落点圈外
     */
    private final int OUTER_LOCATION = 0x0;

    /**
     * switch开关- 事件最开始落点圈内
     */
    private final int INNER_LOCATION = 0x1;

    /**
     * switch开关-事件最开始落点标志位
     */
    private final int LOCATION_MASK = 0x1;

    /**
     * switch开关-非移动事件
     */
    private final int OTHER_EVENT = 0x2;

    /**
     * switch开关-移动事件
     */
    private final int MOVE_EVENT = 0x4;


    /**
     * switch开关-是否处理事件标志位
     */
    private final int EVENT_MASK = 0x6;

    /**
     * switch开关-关闭状态
     */
    private final int STATE_CLOSE = 0x8;

    /**
     * switch开关-打开状态
     */
    private final int STATE_OPEN = 0x10;

    /**
     * switch开关-状态标志位
     */
    private final int STATE_MASK = 0x18;


    /**
     * switch开关-默认状态
     */
    private int mSwitchState;

    /**
     * 用于计算过度颜色
     */
    private ArgbEvaluator mArgbEvaluator;

    /**
     * switch button 状态监听
     */
    private SwitchListener mSwitchListener;

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

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

    public SwitchButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs, defStyleAttr);
        Log.e(TAG, "SwitchButton ");
    }

    private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        //设置为可点击,否则将无法接收到对应的一些列事件也就无法消费事件
        setClickable(true);

        //先将最小的默认值赋予我们存储空间宽高的属性用于onMeasure方法来进行测量
        mWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, defaultMinDimension, getResources().getDisplayMetrics());
        mHeight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, defaultMinDimension / 2.5f, getResources().getDisplayMetrics());

        //获取自定义属性
        TypedArray typedAttributes = context.obtainStyledAttributes(attrs, R.styleable.SwitchButton);
        //默认的内圆半径和外圆半径比 默认值为0.9f
        circleRadioScale = typedAttributes.getFloat(R.styleable.SwitchButton_circleRadioScale, 0.9f);
        //背景和内圆变化的颜色
        mBackgroundCloseColor = typedAttributes.getColor(R.styleable.SwitchButton_backgroundCloseColor, Color.GRAY);
        mBackgroundOpenColor = typedAttributes.getColor(R.styleable.SwitchButton_backgroundOpenColor, Color.WHITE);
        mInnerCircleCloseColor = typedAttributes.getColor(R.styleable.SwitchButton_innerCircleCloseColor, Color.WHITE);
        mInnerCircleOpenColor = typedAttributes.getColor(R.styleable.SwitchButton_innerCircleOpenColor, Color.GRAY);

        //设置动画执行的时长
        mAnimationDuration = typedAttributes.getInt(R.styleable.SwitchButton_animationDuration, 1000);
        typedAttributes.recycle();

        //计算色彩值
        mArgbEvaluator = new ArgbEvaluator();

        //初始化switch状态
        mSwitchState = (STATE_MASK & STATE_CLOSE) | (EVENT_MASK & MOVE_EVENT) | (LOCATION_MASK & OUTER_LOCATION);
        //绘制画笔
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setAntiAlias(true);

        //背景绘制区域
        mBackgroundRectF = new RectF();

        //初始赋值innerCircleOx和innerCircleOy
        innerCircleOx = innerCircleOy = innerCircleMaxLeftX = innerCircleMaxRightX - 1;

        //恢复动画
        mValueAnimator = new ValueAnimator();
        mValueAnimator.addUpdateListener(animation -> {
            innerCircleOx = (float) animation.getAnimatedValue();
            invalidate();
        });
        mValueAnimator.setInterpolator(new BounceInterpolator());
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        //获取view的宽高
        mWidth = w;
        mHeight = h;
        //计算outerCircle的半径
        float radioByWidth = (mWidth - getPaddingLeft() - getPaddingRight()) / 5f;
        //根据宽高来判断radioByWidth是否符合对应的比例
        boolean isResize = radioByWidth * 2 + getPaddingTop() + getPaddingBottom() > mHeight;
        outerCircleRadio = isResize ? (mHeight - getPaddingTop() - getPaddingBottom()) / 2f : radioByWidth;
        //计算背景mBackgroundRectF的left和right
        float mBackgroundRectLeft = isResize ? (mWidth - outerCircleRadio * 5f) / 2 : getPaddingLeft();
        float mBackgroundRectRight = isResize ? (mWidth + outerCircleRadio * 5f) / 2 : mWidth - getPaddingRight();
        // 计算绘制背景区域, 根据背景的宽高比例为2.5f 来计算
        mBackgroundRectF.set(mBackgroundRectLeft, mHeight / 2f - outerCircleRadio, mBackgroundRectRight, mHeight / 2f + outerCircleRadio);
        //计算内圆半径
        innerCircleRadio = outerCircleRadio * circleRadioScale;
        //初始化innerCircleOx的范围
        innerCircleMaxLeftX = mBackgroundRectLeft + outerCircleRadio;
        innerCircleMaxRightX = mBackgroundRectRight - outerCircleRadio;
        //初始化内圆圆心坐标即内圆位置
        innerCircleOx = stateIsOpen() ? innerCircleMaxRightX : innerCircleMaxLeftX;
        innerCircleOy = h >> 1;

    }

    @Override
    protected void onDraw(Canvas canvas) {
        //当前内圆圆心所在位置对应的进度
        float percent = (innerCircleOx - innerCircleMaxLeftX) / (innerCircleMaxRightX - innerCircleMaxLeftX);
        //当前background的color
        int backgroundColor = (int) mArgbEvaluator.evaluate(percent, mBackgroundCloseColor, mBackgroundOpenColor);
        //内圆当前的颜色
        int innerCircleColor = (int) mArgbEvaluator.evaluate(percent, mInnerCircleCloseColor, mInnerCircleOpenColor);

        mPaint.setColor(backgroundColor);
        canvas.drawRoundRect(mBackgroundRectF, outerCircleRadio, outerCircleRadio, mPaint);

        mPaint.setColor(innerCircleColor);
        canvas.drawCircle(innerCircleOx, innerCircleOy, innerCircleRadio, mPaint);
    }

    /**
     * 废除了ClickListener和LongClickListener
     *
     * @param event 事件
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float x = event.getX();
        float y = event.getY();
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
            //记录系列事件最开始事件起始的坐标X
            //判断系列事件最开始事件落点坐标是否在内圆中
            boolean insideInnerCircle = Math.sqrt(Math.pow(Math.abs(x - innerCircleOx), 2) + Math.pow(Math.abs(event.getY() - innerCircleOy), 2)) <= outerCircleRadio;
            //上面判断值存储到mSwitchState中
            onlySetFlag(insideInnerCircle ? INNER_LOCATION : OUTER_LOCATION, LOCATION_MASK);
            //判断当前是否可用、可点击、在点击范围内且不再执行恢复动画
            return isEnabled() && isClickable() && mBackgroundRectF.contains(x, y) && !mValueAnimator.isRunning() && !mValueAnimator.isStarted();
        } else if (event.getAction() == MotionEvent.ACTION_MOVE) {
            //取出对应标记值判断当前是否处于滑动状态,若不处于滑动状态,则更新EVENT_MASK(事件标记位)为对应的值
            if (!judgeIsMoveEvent()) {
                onlySetFlag(MOVE_EVENT, EVENT_MASK);
            }
            //判断当前是否处于业务滑动事件且系列事件最开始事件起始落点在最初状态的内圆内(实际按照外圆半径计算)
            //true 更新内圆innerCircleOx值,更新界面
            //false 不处理
            if (judgeIsMoveEvent() && locationIsInner()) {
                innerCircleOx = x <= innerCircleMaxLeftX ? innerCircleMaxLeftX : x >= innerCircleMaxRightX ? innerCircleMaxRightX : x;
                invalidate();
            }
        } else if (event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL) {
            float animatorStartX, animatorEndX;
            //判断是上个事件是否为业务滑动事件
            if (judgeIsMoveEvent()) {
                //判断系列事件最开始事件落点坐标是否在内圆外
                //是-则恢复EVENT_MASK位为OTHER_EVENT,return中断后续操作
                //否-则进行switchButton状态修改,且开始过度动画过渡到修改的最新值
                onlySetFlag(OTHER_EVENT, EVENT_MASK);
                if (locationIsInner()) {
                    innerCircleOx = x <= innerCircleMaxLeftX ? innerCircleMaxLeftX : x >= innerCircleMaxRightX ? innerCircleMaxRightX : x;
                    boolean stateIsOpen = innerCircleOx > (innerCircleMaxRightX + innerCircleMaxLeftX) / 2f;
                    boolean flagIsChange = setFlagAndGetChangeValue(stateIsOpen ? STATE_OPEN : STATE_CLOSE, STATE_MASK);
                    if (innerCircleOx == innerCircleMaxLeftX || innerCircleOx == innerCircleMaxRightX) {
                        if (mSwitchListener != null&&flagIsChange) {
                            mSwitchListener.switchListener(stateIsOpen);
                        }
                        return true;
                    }
                    animatorStartX = innerCircleOx;
                    animatorEndX = stateIsOpen ? innerCircleMaxRightX : innerCircleMaxLeftX;
                    startSwitchAnimation(animatorStartX, animatorEndX,flagIsChange);
                }
            } else {
                Log.e(TAG, "onTouchEvent click");
                //到此为点击事件,直接修改switchState值,且开始过度动画从老值过渡到最新值
                animatorStartX = innerCircleOx;
                animatorEndX = stateIsOpen() ? innerCircleMaxLeftX : innerCircleMaxRightX;
                onlySetFlag(stateIsOpen() ? STATE_CLOSE : STATE_OPEN, STATE_MASK);
                startSwitchAnimation(animatorStartX, animatorEndX,true);
            }
        }
        return true;
    }

    /**
     * 设置我们的恢复动画相关的属性且开始动画
     *
     * @param animatorStartX 动画开始值
     * @param animatorEndX   动画结束值
     */
    private void startSwitchAnimation(float animatorStartX, float animatorEndX,boolean flagIsChange) {
        mValueAnimator.setFloatValues(animatorStartX, animatorEndX);
        //动态计算动画完成时间
        mValueAnimator.setDuration((long) ((Math.abs(animatorEndX - animatorStartX) * 1L / ((mWidth - getPaddingLeft() - getPaddingRight() / 2L))) * mAnimationDuration));
        mValueAnimator.start();
        //switchState状态监听回调
        if (mSwitchListener != null&&flagIsChange) {
            mSwitchListener.switchListener(stateIsOpen());
        }

    }


    /**
     * 设置我们Switch开关的状态
     *
     * @param state 要设置的switchState的值
     */
    public void setSwitchState(boolean state) {
        boolean isUsefulSetting = setFlagAndGetChangeValue(state ? STATE_OPEN : STATE_CLOSE, STATE_MASK);
        if (isUsefulSetting) {
            float animatorStartX, animatorEndX;
            animatorStartX = innerCircleOx;
            animatorEndX = stateIsOpen() ? innerCircleMaxRightX : innerCircleMaxLeftX;
            startSwitchAnimation(animatorStartX, animatorEndX,true);
        }
    }

    /**
     * 设置当前的Flag且返回当前是否发生了改变,
     *
     * @param flag 需要设置的值
     * @param mask 对应标志位
     * @return 是否发生了改变
     */
    private boolean setFlagAndGetChangeValue(int flag, int mask) {
        int oldState = mSwitchState;
        mSwitchState = (mSwitchState & ~mask) | (flag & mask);
        return (oldState ^ mSwitchState) > 0;
    }

    /**
     * 设置当前的Flag
     *
     * @param flag 需要设置的值
     * @param mask 对应标志位
     */
    private void onlySetFlag(int flag, int mask) {
        mSwitchState = (mSwitchState & ~mask) | (flag & mask);
    }

    /**
     * 从mSwitchState取出switch开关的状态,判断是否为打开状态
     *
     * @return 否为打开状态
     */
    public boolean stateIsOpen() {
        return (mSwitchState & STATE_MASK) == STATE_OPEN;
    }

    /**
     * 从mSwitchState取出event_mask位值,判断当前是否为业务滑动事件
     *
     * @return 是否为业务滑动事件
     */
    public boolean judgeIsMoveEvent() {
        return (mSwitchState & EVENT_MASK) == MOVE_EVENT;
    }

    /**
     * 从mSwitchState取出系列事件ACTION_DOWN事件落点坐标,判断是否在内圆中
     *
     * @return 是否在内圆中
     */
    public boolean locationIsInner() {
        return (mSwitchState & LOCATION_MASK) == INNER_LOCATION;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMeasureMode = MeasureSpec.getMode(widthMeasureSpec);
        int measureWith = MeasureSpec.getSize(widthMeasureSpec);
        int heightMeasureMode = MeasureSpec.getMode(heightMeasureSpec);
        int measureHeight = MeasureSpec.getSize(heightMeasureSpec);

        if (widthMeasureMode != MeasureSpec.EXACTLY) {
            measureWith = mWidth + getPaddingLeft() + getPaddingRight();
        }
        if (heightMeasureMode != MeasureSpec.EXACTLY) {
            measureHeight = getPaddingTop() + getPaddingBottom() + mHeight;
        }
        setMeasuredDimension(measureWith, measureHeight);

    }

    /**
     * 回调状态
     */
    public interface SwitchListener {
        void switchListener(boolean open);
    }

    public void setSwitchListener(SwitchListener switchListener) {
        mSwitchListener = switchListener;
    }

    @Nullable
    @Override
    protected Parcelable onSaveInstanceState() {
        Parcelable parcelable = super.onSaveInstanceState();
        SaveState saveState = new SaveState(parcelable);
        saveState.switchState = mSwitchState;
        return saveState;
    }

    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        if (state instanceof SaveState) {
            SaveState saveState = (SaveState) state;
            super.onRestoreInstanceState(saveState.getSuperState());
            mSwitchState = saveState.switchState;
        } else {
            super.onRestoreInstanceState(state);
        }

    }

    static class SaveState extends BaseSavedState {

        //对应SwitchButton的mSwitchState属性
        private int switchState;

        public SaveState(Parcel source) {
            super(source);
            switchState = source.readInt();
        }

        public SaveState(Parcelable superState) {
            super(superState);
        }

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeInt(switchState);
        }

        public static final Parcelable.Creator<SaveState> CREATOR = new Creator<SaveState>() {
            @Override
            public SaveState createFromParcel(Parcel source) {
                return new SaveState(source);
            }

            @Override
            public SaveState[] newArray(int size) {
                return new SaveState[size];
            }
        };

    }

}
           
本来还有些其他的东西要分享的,但是这篇写的和代码贴的实在太多了,不得不在此结束了,如果本篇文章有不对或者描述不恰当的地方希望大家指出,希望大家给个赞哦!