自定义View-SwitchButton
记得第一次写自定义View的时候,写就是switchbutton,现在回想起当时码的,感觉真的是很low爆了,前段时间业务要求需要用,虽然现在android系统提供了SwitchButton,但是还是想自己写一个来场回忆杀,哈哈!下面来看一下效果图
一、 分析View
如图
- 由内圆和一个两边为半圆(以下都简称为外圆)的长方形背景;
1. 我们定义内圆半径r
为外圆半径R
的0.9
倍:/** * 外圆半径 */ private float outerCircleRadio; /** * 内圆半径 */ private float innerCircleRadio;
-
为了更加美观,定义
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;
-
为了区别开关状态,在每个状态分别在对应的状态给对应的内圆和背景不同的颜色来以区分;
-
定义
SwitcButton
对应状态内圆的颜色:/** * 内圆未选中时的颜色 */ @ColorInt private int mInnerCircleOpenColor; /** * 内圆选中时的颜色 */ @ColorInt private int mInnerCircleCloseColor;
-
定义
SwitcButton
对应状态背景的颜色:/** * 背景区域选中颜色 */ @ColorInt private int mBackgroundOpenColor; /** * 背景区域未选中颜色 */ @ColorInt private int mBackgroundCloseColor;
二、最简单的实现
按照上面的分析我们先不管其他因素,先按照最简单的来实现
-
创建类
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); } }
-
定义上面需要的属性:
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); } }
-
定义方法
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(); }
-
重写方法
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); }
-
重写方法
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);
-
我们最初的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>
然后运行为:
2)我们给View添加padding值为50dp在运行如下:
看来我们模型大致完成了,接下来就是完成
swtich
功能
三、实现Switch
功能
有两种方式可以改变
SwtichButton
的状态:
第一种:通过点击操作来切换更改SwitchButton的操作
第二种:通过滑动内圆来更改SwtichButton
的状态
因此我们要在处理ACTION_UP和ACTION_CANCEL事件中对当前一系列的事件进行判断,判断是否仅仅是单击事件还是中间出现了对应的业务滑动,如果仅仅是单击事件则进行状态的转换且更新内颜色位置和背景的颜色,若是滑动事件我们还要进行另一个判断,判断我们当前系列事件的down事件落点是否在当前状态下内圆内,是我们则将更新滑动的进度且补全剩下状态改变的过渡过程,
-
由上分析可知我们需要定义两个标志位分别来标记当前一系列事件是否是含有滑动事件和当前一系列事件的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;
-
为了让我们
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(); }
-
为了更直观的可读性,我们创建修改标志位的方法和一些读取对应标志位的方法,并且将其装换位对应
布尔值
:
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; }
-
在使用非点击事件来改变
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; //省略 }
-
分析
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(); } }
-
分析
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(); }
-
事件结束处理
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);
运行看一下我们的效果:
从效果来看我们大致实现我们的功能,但是不够美观我们之前的状态颜色也没有用上,所以我们需让颜色对号入座 -
为了使我们的
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); }
运行效果为:
从效果上来我们对颜色的渐变已经达到了我们的效果,nice!
四、测量View
因为自己每次自定义View时,都会非常纠结这部分,因为涉及到测量模式
wrap_content
,需要自己业务的需要和实际考虑来返回View的测量宽高
-
未重写方法
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>
运行看一下效果:
what ?SwitcButton呢?
由图中可知道我们View在实实在在的存在的,但是我们的SwitchButton
却不见了,这是由于我们的实际绘制区域宽度变为了0
,我们在设置宽度为100dp
,而padding
值为50dp
,我们在计算外圆半径的计算方式为(w - getPaddingLeft() - getPaddingRight()) / 5f
,也就是100dp-50dp-50dp=0dp
也就是宽度为0
了自然也就没有空白了,当然只是我们需要自己重写onMeasure
方法的特殊情况,所以我们修改padding
值为10dp
在重新运行一下为:
这样我们就要显示出来,但是却为突出不重写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>
运行效果如图:
surprise!
这样我们的问题就凸显出来了,因为在规定SwitchButton的背景宽度
是其高度的2.5倍
,而我们的SwitchButton的宽度
为w - getPaddingLeft() - getPaddingRight()
,那么SwitchButton的高度为:(w - getPaddingLeft() - getPaddingRight())*2/5f
,而之前我们设置了控件的高度
为固定值100dp
,此时由于SwitchButton的高度
是几乎充满水平防线的屏幕宽度的,也就是说SwitchButton的高度
是有很大的可能大于100dp
的,这也就造成了我们View
控件提供的高度不足以绘制我们的SwitchButton背景
,也就是现在显示的残缺不全的效果,因此我们需要自己根据View的实际可绘制的宽高来配置我们SwitchButton
宽高,进行绘制,所以我们就需要重写onMeasure
方法。 -
重写
onMeasure
方法,关于onMeasure
的一些知识这里就不在介绍了,需要了解的自行去搜索
1)MeasureSpec.EXACTLY
模式即match_parent或者是给定确切固定值
,这里比较简单,如果是这模式我们直接采用系统给我们侧脸好的值返回就行了,后面再根据实际的业务需要来计算我们对应的属性(这里没什么好说的)
2)MeasureSpec.AT_MOST
模式即warp_content
,这里是我最纠结的部分了,因为在这个模式系统会给我们提供当前parent
能提供给的最大空间,我们可以选择给自己设定的最小默认值来进行绘制,也可以根据parent
提供的最大空间结合业务逻辑来计算对应的属性,这里先采用给予设定的默认值来计算绘制我们的SwitchButton
3)MeasureSpec.UNSPECIFIED
模式,这种方式未指定尺寸,这种模式用的特别的少,这里也没什么好讲的,直接舍去,下面就开始就行我们实际编码onMeasure
方法 -
根据我们的业务逻辑重写
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); }
-
测量完
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;
}
再次运行之前测试的配置如下:
完美解决!
五、SwitchButton
状态的保存和恢复
在日常使用app过程中手机有时难免会横放,致使屏幕旋转,而这时我们的View经历销毁然后重建,然后再回复之前的状态,系统提供的TextView等控件只要我们给其唯一的
id
,系统都能正确恢复其销毁前的状态,那我们看看我们继承View来码的SwitchButton
在屏幕发生旋转时系统会不会为其恢复,当然我们也会给其唯一的Id
(至于为什么需要给其唯一的ID系统才能恢复等不是本篇探讨的内容想要深入了解的可以去网上搜索相关资料或者阅读源码),测试效果图如下,先将SwitchButton
打开然后再旋转屏幕,发现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);
}
}
至此我们的属性的保存及恢复就完成了,以下为测试结果,由此可见我们我们恢复效果达到了:
六、给SwitchButton
添加状态监听
其实这个过程还是比较简单的,直接开码
-
定义接口
SwitchListener
/** * 回调状态 */ public interface SwitchListener { void switchListener(boolean open); }
-
给
SwitchButton
添加SwitchListener
相关配置操作/** * switch button 状态监听 */ private SwitchListener mSwitchListener; public void setSwitchListener(SwitchListener switchListener) { mSwitchListener = switchListener; }
-
在合适的地方法进行回调我们的监听函数,思考一下,无非就是判断当前是否发生了
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
,测试结果为:
从测试结果来看我们的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)); } }
运行测试结果如下:
七、自定义属性
其实到达这一步骤,基本上我们的
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];
}
};
}
}
本来还有些其他的东西要分享的,但是这篇写的和代码贴的实在太多了,不得不在此结束了,如果本篇文章有不对或者描述不恰当的地方希望大家指出,希望大家给个赞哦!