自定义View-仿Android原生动画效果的圆形Checkbox

Posted by Williamic on 2020-05-08

1.概述

   Android原生的方形Checkbox带有画勾的动画效果,转换成圆形Checkbox则不生成,于是决定通过自定义View来实现,下面先上实现后的效果图。

2.实现步骤

   为了实现效果,先将原生控件进行步骤分解。主要实现分为三个部分,绘制背景、绘制勾以及绘制水波纹。

1)绘制背景

   原生的方形Checkbox的背景是用类似裁剪方式来绘制的,因为当控件的布局添加了背景后会呈现出来,如下图所示。所以这里我们通过Path的填充方式绘制背景。
原生Checkbox

- 绘制一个圆环

   这里的圆环作为控件的初始界面,通过Path方法来绘制。首先给Path设置填充方法,这里设置为EVEN_ODD,Path的填充方式有四种

  • EVEN_ODD
  • WINDING (默认值)
  • INVERSE_EVEN_ODD
  • INVERSE_WINDING

   EVEN_ODD是交叉填充,而WINDING是全填充,后面的两个带有 INVERSE_ 前缀的,是前两个的反色版本,所以主要介绍前2种。具体的原理就是利用射线算法来判断点在图形的内外部,从而根据内外部和Path的绘制方向来填充颜色。这里用Path绘制两个一大一小的圆,选择EVEN_ODD填充方式来画出圆环。   代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private Path  mRoundPath;
private Paint mBackPaint;
private int mStrokeWidth; //圆环的宽度
private Point mCenterPoint; //中心点


mRoundPath = new Path();
//设置填充类型
mRoundPath.setFillType(Path.FillType.EVEN_ODD);
//抗锯齿
mBackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBackPaint.setStyle(Paint.Style.FILL);

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
mWidth = getMeasuredWidth();
mHeight = getMeasuredHeight();

mCenterPoint.x = getMeasuredWidth() / 2;
mCenterPoint.y = getMeasuredHeight() / 2;
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//刷新路径
mRoundPath.reset();
//绘制大圆
float backRadius = ((float) Math.min(mWidth, mHeight) / 32 * 11);
mRoundPath.addCircle(mCenterPoint.x, mCenterPoint.y, backRadius, Path.Direction.CW);
//绘制小圆
mStrokeWidth = mWidth / 16;
float roundRadius = ((float) Math.min(mWidth, mHeight) / 32 * 11 - (float) mStrokeWidth);
mRoundPath.addCircle(mCenterPoint.x, mCenterPoint.y, roundRadius, Path.Direction.CW);
//画出圆环
mBackPaint.setColor(mColorBack);
canvas.drawPath(mRoundPath, mBackPaint);
}

   此时画出的圆环图是这样的。
圆环图
   接下来需要把内圆的半径逐渐减少,这样被填充圆环会不断内扩直到变成一个圆。这里需要用到属性动画,将内圆的半径参数作为变化值,采用线性插值器,将半径数从1-0逐渐递减,同时将颜色从mColorUnSelected(未选中时颜色)渐变到mColorSelected(选中后的颜色)代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ValueAnimator animator = ValueAnimator.ofFloat(1.0f, 0f);
animator.setDuration(mStartAnimDuration / 2);
//线性插值器
animator.setInterpolator(new LinearInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
mRoundVal = (float) valueAnimator.getAnimatedValue();
//颜色渐变
mColorBack = getHsvColor(1.0f - mRoundVal, mColorUnSelected, mColorSelected);
postInvalidate();
}
});
animator.start();

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//......省略代码
//绘制小圆,添加mRoundVal控制内圈缩放动画
float roundRadius = ((float) Math.min(mWidth, mHeight) / 32 * 11 - (float) mStrokeWidth) * mRoundVal;
mRoundPath.addCircle(mCenterPoint.x, mCenterPoint.y, roundRadius, Path.Direction.CW);
//......省略代码
}

   同样的,当从选中改为未选中时,将mRoundVal从0变到1即可,颜色渐变回去,如下图所示。
动图1
   接下来我们可以注意到,原生的Checkbox在变换的时候,外轮廓有一个小的缩小和放大的过程,所以我们也对自定义的Checkbox外轮廓的半径设置属性动画,将值在1.0-0.8-1.0的范围进行一次属性变化,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 private float mBackVal;

ValueAnimator backAnimator = ValueAnimator.ofFloat(1.0f, 0.8f, 1.0f);
backAnimator.setDuration(mStartAnimDuration);
backAnimator.setInterpolator(new LinearInterpolator());
backAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
mBackVal = (float) valueAnimator.getAnimatedValue();
postInvalidate();
}
});
backAnimator.start();

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//......省略代码
//绘制大圆,添加mBackVal控制外圈缩放动画
float backRadius = ((float) Math.min(mWidth, mHeight) / 32 * 11) * mBackVal;
mRoundPath.addCircle(mCenterPoint.x, mCenterPoint.y, backRadius, Path.Direction.CW);
//......省略代码
}

   此时的效果如下图。
动图2
   这里可能会疑惑,为什么不直接用canvas.drawCircle方法,通过给Paint设置Stroke来画一个圆环。因为用drawCircle方法画出的圆环的大小并不是固定的,设置的线条的宽度只会使圆的外圈和内圈的间距达到线条宽度,实际的半径为外圈半径减去二分之一先线宽。动图2   所以因为大小不固定,从而导致变换的数值不便于计算,且无法同时控制两个外圈和内圈的缩放。

2)绘制勾

   背景绘制成功之后,接下来绘制勾。观察原生的Checkbox会发现,勾是左右两端同时进行的,而不是从左侧到右侧的顺序进行绘制。
动图3   所以这里需要用到两个Path来绘制,leftPath绘制左侧部分线条,rightPath绘制右侧部分线条,两个线段的夹角为45度。绘制路径这里需要确定三个点,勾的拐点,左终点和右终点,点的具体位置通过计算得到,这里就不在演算了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
mWidth = getMeasuredWidth();
mHeight = getMeasuredHeight();
//......省略代码
if (mWidth == mHeight) {
mTickPoints[0].x = Math.round((float) getMeasuredWidth() / 32 * 10);
mTickPoints[0].y = Math.round((float) getMeasuredHeight() / 32 * 16);
mTickPoints[1].x = Math.round((float) getMeasuredWidth() / 32 * 14);
mTickPoints[1].y = Math.round((float) getMeasuredHeight() / 32 * 20);
mTickPoints[2].x = Math.round((float) getMeasuredWidth() / 32 * 21);
mTickPoints[2].y = Math.round((float) getMeasuredHeight() / 32 * 13);
mStrokeWidth = mWidth / 16;
} else {
int min = Math.min(mWidth, mHeight);
mTickPoints[0].x = Math.round((float) getMeasuredWidth() / 2 - (float) min / 32 * 6);
mTickPoints[0].y = Math.round((float) getMeasuredHeight() / 2);
mTickPoints[1].x = Math.round((float) getMeasuredWidth() / 2 - (float) min / 32 * 2);
mTickPoints[1].y = Math.round((float) getMeasuredHeight() / 2 + (float) min / 32 * 4);
mTickPoints[2].x = Math.round((float) getMeasuredWidth() / 2 + (float) min / 32 * 5);
mTickPoints[2].y = Math.round((float) getMeasuredHeight() / 2 - (float) min / 32 * 3);
mStrokeWidth = min / 16;
}
//确定线的宽度
mTickPaint.setStrokeWidth(mStrokeWidth);
}

   点确定完之后,通过Path.lineTo来确定绘制的路线,同样利用属性动画,设置一个tickVal参数,从0-1进行线性变化,从而使得左右线段的终点在发生变化,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private float mTickVal;

postDelayed(new Runnable() {
@Override
public void run() {
mDraTick = true;
ValueAnimator animator = ValueAnimator.ofFloat(0f, 1.0f);
animator.setDuration(mStartAnimDuration / 2);
animator.setInterpolator(new LinearInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
mTickVal = (float) valueAnimator.getAnimatedValue();
postInvalidate();
}
});
animator.start();
}
}, mStartAnimDuration / 2);

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//......省略代码
if (mDraTick && isChecked()) {
mTickLeftPath.reset();
//drawLeftTick
mTickLeftPath.moveTo(mTickPoints[1].x, mTickPoints[1].y);
float stopLeftX = ((float) mTickPoints[0].x - mTickPoints[1].x) * mTickVal + mTickPoints[1].x;
float stopLeftY = ((float) mTickPoints[0].y - mTickPoints[1].y) * mTickVal + mTickPoints[1].y;
mTickLeftPath.lineTo(stopLeftX, stopLeftY);
canvas.drawPath(mTickLeftPath, mTickPaint);

mTickRightPath.reset();
//drawRightTick
mTickRightPath.moveTo(mTickPoints[1].x, mTickPoints[1].y);
float stopRightX = ((float) mTickPoints[2].x - mTickPoints[1].x) * mTickVal + mTickPoints[1].x;
float stopRightY = ((float) mTickPoints[2].y - mTickPoints[1].y) * mTickVal + mTickPoints[1].y;
mTickRightPath.lineTo(stopRightX, stopRightY);
canvas.drawPath(mTickRightPath, mTickPaint);
}
}

   效果如下图所示。
动图4

3)绘制水波纹

   如果水波纹未设置特定颜色,默认与其当前状态下的背景颜色相同。这里需要注意,色值需要添加透明度,否则颜色会太深。水波纹利用RippleDrawable实现,需要SDK大于21。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//代码编写使用有参构造函数
public RippleDrawable(@NonNull ColorStateList color, @Nullable Drawable content,
@Nullable Drawable mask) {
this(new RippleState(null, null, null), null);
//color: 波纹的颜色,必须要有,没有会抛出异常
if (color == null) {
throw new IllegalArgumentException("RippleDrawable requires a non-null color");
}
//content: 背景,当背景为null时,波纹为无界.不为null时为有界.
if (content != null) {
addLayer(content, null, 0, 0, 0, 0, 0);
}
//mask: mask是不会被draw的,但是它会限制波纹的边界,如果为null,默认为content的边界,当content为null就没有边界了.
if (mask != null) {
addLayer(mask, null, android.R.id.mask, 0, 0, 0, 0);
}
//省略无关代码...
}

   设置水波纹代码。

1
2
3
4
5
6
7
8
9
10
11
12
private void setBackRipple(@ColorInt int color) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
setBackground(new RippleDrawable(ColorStateList.valueOf(getColorWithAlpha(mRippleAlpha, color)),
null, null));
}
}
//设置色值的透明度
private int getColorWithAlpha(float alpha, int baseColor) {
int a = Math.min(255, Math.max(0, (int) (alpha * 255))) << 24;
int rgb = 0x00FFFFFF & baseColor;
return a + rgb;
}

   效果如下。
动图5

3.总结

   RoundCheckbox主要利用的是属性动画和Path来完成,需要注意的是Path的填充方式setFillType,它的实现也与图形绘制的方向有关,下次争取单独总结一篇进行介绍。同时在实际应用中也要注意一点,尽量减少不可见UI的绘制,这样会导致不必要的性能损耗。还有就是在onDraw中不要创建对象,因为onDraw会在绘制的时候会被频繁调用,进而导致对象频繁被创建,严重时会导致内存抖动。
   最后上一张对比图。动图6   代码在这里