1.概述
Android原生的方形Checkbox带有画勾的动画效果,转换成圆形Checkbox则不生成,于是决定通过自定义View来实现,下面先上实现后的效果图。
2.实现步骤
为了实现效果,先将原生控件进行步骤分解。主要实现分为三个部分,绘制背景、绘制勾以及绘制水波纹。
1)绘制背景
原生的方形Checkbox的背景是用类似裁剪方式来绘制的,因为当控件的布局添加了背景后会呈现出来,如下图所示。所以这里我们通过Path的填充方式绘制背景。
- 绘制一个圆环
这里的圆环作为控件的初始界面,通过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即可,颜色渐变回去,如下图所示。
接下来我们可以注意到,原生的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); //......省略代码 }
|
此时的效果如下图。
这里可能会疑惑,为什么不直接用canvas.drawCircle方法,通过给Paint设置Stroke来画一个圆环。因为用drawCircle方法画出的圆环的大小并不是固定的,设置的线条的宽度只会使圆的外圈和内圈的间距达到线条宽度,实际的半径为外圈半径减去二分之一先线宽。 所以因为大小不固定,从而导致变换的数值不便于计算,且无法同时控制两个外圈和内圈的缩放。
2)绘制勾
背景绘制成功之后,接下来绘制勾。观察原生的Checkbox会发现,勾是左右两端同时进行的,而不是从左侧到右侧的顺序进行绘制。
所以这里需要用到两个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); } }
|
效果如下图所示。
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; }
|
效果如下。
3.总结
RoundCheckbox主要利用的是属性动画和Path来完成,需要注意的是Path的填充方式setFillType,它的实现也与图形绘制的方向有关,下次争取单独总结一篇进行介绍。同时在实际应用中也要注意一点,尽量减少不可见UI的绘制,这样会导致不必要的性能损耗。还有就是在onDraw中不要创建对象,因为onDraw会在绘制的时候会被频繁调用,进而导致对象频繁被创建,严重时会导致内存抖动。
最后上一张对比图。 代码在这里