前言
在项目中使用到了 github 上的开源项目 SmoothCheckBox,觉得值得学习。
大胆猜测
从动画效果可以看出来,效果该分为三个部分:
一:对勾 是一部分, 这应该是使用了 drawLine 的相关API,难点在于怎么动态的画出对勾。
二:内容的填充色 ,这一部分,该是 drawCircle 的相关API,难点则在于平滑的重绘。
三:最后,border 也是单独一部分,应该也是 drawCircle 的相关API,问题在于使用的巧妙。
作者思路
一般而言,我看自定义控件会先看 onDraw 方法,纯属个人习惯。因为,onDraw 方法里面的方法和参数往往最为直观。待熟悉了作者的思路,并且把大部分的变量都了然于胸,这个时候才不会看得一头雾水。待看完 onDraw,然后再从 属性构造方法(initAttrs),onMeasure, onLayout, onDraw这样的顺序看起,这也是绘制View的顺序。
onDraw
@Override
protected void onDraw(Canvas canvas) {
drawBorder(canvas);
drawCenter(canvas);
drawTick(canvas);
}
显然,作者的思路也是这样,将整个控件分为边框/填充/对勾。
在阅读这三个方法方法,并联合其它的方法,那么代码中各个重要的参数的意义就明白了:
各项参数
参数名 | 参数含义 |
---|---|
mPaint | 中心空白区域的画笔 |
mTickPaint | 对勾画笔 |
mFloorPaint | 处理边框的画笔(实际上边框的形成,是一个比 mPaint 更大的圆) |
mCenterPoint | 控件中心点 |
mTickPoints | 对勾坐标点数组 |
mTickPath | 对勾画笔的Path路径 |
mLeftLineDistance | 对勾折线左侧的长度 |
mRightLineDistance | 对勾折线右侧的长度 |
mDrewDistance | 对勾折线已完成绘制的长度 |
mScaleVal | 中心填充圆半径的缩放比列 |
mFloorScale | 选中以及border的圆半径缩放比列 |
mTickDrawing | 是否绘制对勾 |
应该说比较重要的一些属性都在这里了,当然也还有一些比较琐碎的参数,不过,那都无关紧要,看一眼就明白了。
View 的绘制
initAttrs
private void init(AttributeSet attrs) {
TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.SmoothCheckBox);
int tickColor = ta.getColor(R.styleable.SmoothCheckBox_color_tick, COLOR_TICK);
mAnimDuration = ta.getInt(R.styleable.SmoothCheckBox_duration, DEF_ANIM_DURATION);
mFloorColor = ta.getColor(R.styleable.SmoothCheckBox_color_unchecked_stroke, COLOR_FLOOR_UNCHECKED);
mCheckedColor = ta.getColor(R.styleable.SmoothCheckBox_color_checked, COLOR_CHECKED);
mUnCheckedColor = ta.getColor(R.styleable.SmoothCheckBox_color_unchecked, COLOR_UNCHECKED);
mStrokeWidth = ta.getDimensionPixelSize(R.styleable.SmoothCheckBox_stroke_width, CompatUtils.dp2px(getContext(), 0));
ta.recycle();
mFloorUnCheckedColor = mFloorColor;
mTickPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTickPaint.setStyle(Paint.Style.STROKE);
mTickPaint.setStrokeCap(Paint.Cap.ROUND);
mTickPaint.setColor(tickColor);
mFloorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mFloorPaint.setStyle(Paint.Style.FILL);
mFloorPaint.setColor(mFloorColor);
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mCheckedColor);
mTickPath = new Path();
mCenterPoint = new Point();
mTickPoints = new Point[3];
mTickPoints[0] = new Point();
mTickPoints[1] = new Point();
mTickPoints[2] = new Point();
setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
toggle();
mTickDrawing = false;
mDrewDistance = 0;
if (isChecked()) {
startCheckedAnimation();
} else {
startUnCheckedAnimation();
}
}
});
}
initAttrs 这个方法其实没有什么,只是将属性都取了出来,当然这里初始化了View的点击是件,我们先不做讨论,这个还是等看完整个绘制流程在来看好了。
onMeasure
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
setMeasuredDimension(measureSize(widthMeasureSpec), measureSize(heightMeasureSpec));
}
private int measureSize(int measureSpec) {
int defSize = CompatUtils.dp2px(getContext(), DEF_DRAW_SIZE);
int specSize = MeasureSpec.getSize(measureSpec);
int specMode = MeasureSpec.getMode(measureSpec);
int result = 0;
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
case MeasureSpec.AT_MOST:
result = Math.min(defSize, specSize);
break;
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
onMeasure 这个方法仅仅只是设置了默认的大小,这是自定义View的标准测量代码,也没什么好说的。如果对 measureSize 这个方法不太明白这写判断,恩,我推介这篇文章 Android View的绘制流程。
onLayout
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
mWidth = getMeasuredWidth();
mStrokeWidth = (mStrokeWidth == 0 ? getMeasuredWidth() / 10 : mStrokeWidth);
mStrokeWidth = mStrokeWidth > getMeasuredWidth() / 5 ? getMeasuredWidth() / 5 : mStrokeWidth;
mStrokeWidth = (mStrokeWidth < 3) ? 3 : mStrokeWidth;
mCenterPoint.x = mWidth / 2;
mCenterPoint.y = getMeasuredHeight() / 2;
mTickPoints[0].x = Math.round((float) getMeasuredWidth() / 30 * 7);
mTickPoints[0].y = Math.round((float) getMeasuredHeight() / 30 * 14);
mTickPoints[1].x = Math.round((float) getMeasuredWidth() / 30 * 13);
mTickPoints[1].y = Math.round((float) getMeasuredHeight() / 30 * 20);
mTickPoints[2].x = Math.round((float) getMeasuredWidth() / 30 * 22);
mTickPoints[2].y = Math.round((float) getMeasuredHeight() / 30 * 10);
mLeftLineDistance = (float) Math.sqrt(Math.pow(mTickPoints[1].x - mTickPoints[0].x, 2) +
Math.pow(mTickPoints[1].y - mTickPoints[0].y, 2));
mRightLineDistance = (float) Math.sqrt(Math.pow(mTickPoints[2].x - mTickPoints[1].x, 2) +
Math.pow(mTickPoints[2].y - mTickPoints[1].y, 2));
mTickPaint.setStrokeWidth(mStrokeWidth);
}
可以看到作者在写的时候,考虑的真的挺多,对 mStrokeWidth 的各种判断测量, 将大小限制在了 3 ~ getMeasuredWidth/5 之间。
mStrokeWidth = (mStrokeWidth == 0 ? getMeasuredWidth() / 10 : mStrokeWidth);
mStrokeWidth = mStrokeWidth > getMeasuredWidth() / 5 ? getMeasuredWidth() / 5 : mStrokeWidth;
mStrokeWidth = (mStrokeWidth < 3) ? 3 : mStrokeWidth;
然后是对 mTickPoints 的初始化,将对勾部分的3个点的坐标确定下来,那么对勾的3个点的坐标分别是(7 / 30 * w, 14 / 30 * h), (13 / 30 w, 20 / 30 h), (22 / 30 * w, 10 / 30 * h)。
mTickPoints[0].x = Math.round((float) getMeasuredWidth() / 30 * 7);
mTickPoints[0].y = Math.round((float) getMeasuredHeight() / 30 * 14);
mTickPoints[1].x = Math.round((float) getMeasuredWidth() / 30 * 13);
mTickPoints[1].y = Math.round((float) getMeasuredHeight() / 30 * 20);
mTickPoints[2].x = Math.round((float) getMeasuredWidth() / 30 * 22);
mTickPoints[2].y = Math.round((float) getMeasuredHeight() / 30 * 10);
最后是对对勾转折点左侧,右侧长度的测量
public static long pow(double x,double y) 传回x的y次幂值
public static long sqrt(double x) 传回x开平方值
mLeftLineDistance = (float) Math.sqrt(Math.pow(mTickPoints[1].x - mTickPoints[0].x, 2) +
Math.pow(mTickPoints[1].y - mTickPoints[0].y, 2));
mRightLineDistance = (float) Math.sqrt(Math.pow(mTickPoints[2].x - mTickPoints[1].x, 2) +
Math.pow(mTickPoints[2].y - mTickPoints[1].y, 2));
onlayout 中做的事情还是比较多的,其实从这里的代码就可以看出,这个控件真正的难度就在于对勾的动态绘制。
onDraw
终于到了比较重要的部分了
@Override
protected void onDraw(Canvas canvas) {
drawBorder(canvas);
drawCenter(canvas);
drawTick(canvas);
}
这里,我建议先只看 drawBorder(canvas) , drawCenter(canvas),这两个方法,比较简单,复杂/代码多的,放到最后慢慢啃。
private void drawCenter(Canvas canvas) {
mPaint.setColor(mUnCheckedColor);
float radius = (mCenterPoint.x - mStrokeWidth) * mScaleVal;
canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, radius, mPaint);
}
private void drawBorder(Canvas canvas) {
mFloorPaint.setColor(mFloorColor);
int radius = mCenterPoint.x;
canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, radius * mFloorScale, mFloorPaint);
}
这两个方法就可以看出,控件选中时的填充色和为选中的填充色是由两个圆所构成的,而 border 是使用两个圆的半径差构成的,当然,请先记住 mScaleVal 和 mFloorScale 这两个很重要的参数,这是我们实现效果的重要参数!
接下来,是解决我们大胆猜测的问题的时候了:
二:内容的填充色 ,这一部分,该是 drawCircle 的相关API,难点则在于平滑的重绘。
三:最后,border 也是单独一部分,应该也是 drawCircle 的相关API,问题在于使用的巧妙。
其实只要使用过属性动画,就可以看出这里View的重绘多半是使用了属性动画,毕竟有这么简便的东西,不至于去写定时器吧…至少我是这么觉得。
那么接下来就需要看看在 initAttrs 时,点击事件中的 startCheckedAnimation() 和 startUnCheckedAnimation(),先看 startCheck 部分,只要把这个看明白了,unCheck 其实也是一样的。
private void startCheckedAnimation() {
ValueAnimator animator = ValueAnimator.ofFloat(1.0f, 0f);
animator.setDuration(mAnimDuration / 3 * 2);
animator.setInterpolator(new LinearInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mScaleVal = (float) animation.getAnimatedValue();
mFloorColor = getGradientColor(mUnCheckedColor, mCheckedColor, 1 - mScaleVal);
postInvalidate();
}
});
animator.start();
ValueAnimator floorAnimator = ValueAnimator.ofFloat(1.0f, 0.8f, 1.0f);
floorAnimator.setDuration(mAnimDuration);
floorAnimator.setInterpolator(new LinearInterpolator());
floorAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mFloorScale = (float) animation.getAnimatedValue();
postInvalidate();
}
});
floorAnimator.start();
drawTickDelayed();
}
drawTickDelayed()可以先不看,这是用来绘制对勾的方法。在 startCheck 方法中,使用了两个属性动画,分别对 mScaleVal / mFloorColor / mFloorScale 这三个参数进行了改变,在重绘过程中,drawCenter 部分会逐渐变小,drawBorder 则只是做了一个缩放动画, 另外颜色值也呈线性改变。这里可以看下改变颜色的方法:
private static int getGradientColor(int startColor, int endColor, float percent) {
int startA = Color.alpha(startColor);
int startR = Color.red(startColor);
int startG = Color.green(startColor);
int startB = Color.blue(startColor);
int endA = Color.alpha(endColor);
int endR = Color.red(endColor);
int endG = Color.green(endColor);
int endB = Color.blue(endColor);
int currentA = (int) (startA * (1 - percent) + endA * percent);
int currentR = (int) (startR * (1 - percent) + endR * percent);
int currentG = (int) (startG * (1 - percent) + endG * percent);
int currentB = (int) (startB * (1 - percent) + endB * percent);
return Color.argb(currentA, currentR, currentG, currentB);
}
如果看明白了 check 方法,那么 unCheck 看一眼也就明白了
private void startUnCheckedAnimation() {
ValueAnimator animator = ValueAnimator.ofFloat(0f, 1.0f);
animator.setDuration(mAnimDuration);
animator.setInterpolator(new LinearInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mScaleVal = (float) animation.getAnimatedValue();
mFloorColor = getGradientColor(mCheckedColor, mFloorUnCheckedColor, mScaleVal);
postInvalidate();
}
});
animator.start();
ValueAnimator floorAnimator = ValueAnimator.ofFloat(1.0f, 0.8f, 1.0f);
floorAnimator.setDuration(mAnimDuration);
floorAnimator.setInterpolator(new LinearInterpolator());
floorAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mFloorScale = (float) animation.getAnimatedValue();
postInvalidate();
}
});
floorAnimator.start();
}
至此可以说,背景的动效 / 颜色变换 / 两个圆的绘制巧妙 都已经OK了,那么接下来,就来看 对勾的绘制 – drawTick(canvas).
private void drawTick(Canvas canvas) {
if (mTickDrawing && isChecked()) {
drawTickPath(canvas);
}
}
private void drawTickPath(Canvas canvas) {
mTickPath.reset();
// draw left of the tick
if (mDrewDistance < mLeftLineDistance) {
float step = (mWidth / 20.0f) < 3 ? 3 : (mWidth / 20.0f);
mDrewDistance += step;
float stopX = mTickPoints[0].x + (mTickPoints[1].x - mTickPoints[0].x) * mDrewDistance / mLeftLineDistance;
float stopY = mTickPoints[0].y + (mTickPoints[1].y - mTickPoints[0].y) * mDrewDistance / mLeftLineDistance;
mTickPath.moveTo(mTickPoints[0].x, mTickPoints[0].y);
mTickPath.lineTo(stopX, stopY);
canvas.drawPath(mTickPath, mTickPaint);
if (mDrewDistance > mLeftLineDistance) {
mDrewDistance = mLeftLineDistance;
}
} else {
mTickPath.moveTo(mTickPoints[0].x, mTickPoints[0].y);
mTickPath.lineTo(mTickPoints[1].x, mTickPoints[1].y);
canvas.drawPath(mTickPath, mTickPaint);
// draw right of the tick
if (mDrewDistance < mLeftLineDistance + mRightLineDistance) {
float stopX = mTickPoints[1].x + (mTickPoints[2].x - mTickPoints[1].x) * (mDrewDistance - mLeftLineDistance) / mRightLineDistance;
float stopY = mTickPoints[1].y - (mTickPoints[1].y - mTickPoints[2].y) * (mDrewDistance - mLeftLineDistance) / mRightLineDistance;
mTickPath.reset();
mTickPath.moveTo(mTickPoints[1].x, mTickPoints[1].y);
mTickPath.lineTo(stopX, stopY);
canvas.drawPath(mTickPath, mTickPaint);
float step = (mWidth / 20) < 3 ? 3 : (mWidth / 20);
mDrewDistance += step;
} else {
mTickPath.reset();
mTickPath.moveTo(mTickPoints[1].x, mTickPoints[1].y);
mTickPath.lineTo(mTickPoints[2].x, mTickPoints[2].y);
canvas.drawPath(mTickPath, mTickPaint);
}
}
// invalidate
if (mDrewDistance < mLeftLineDistance + mRightLineDistance) {
postDelayed(new Runnable() {
@Override
public void run() {
postInvalidate();
}
}, 10);
}
}
mTickDrawing 默认便是 false,startCheckedAnimation() 方法中的 drawTickDelayed() 才是触发对勾绘制的起始点:
private void drawTickDelayed() {
postDelayed(new Runnable() {
@Override
public void run() {
mTickDrawing = true;
postInvalidate();
}
}, mAnimDuration);
}
当背景动画完成时,对勾开始绘制,从顶层的逻辑上分析,先画左边,再画右边,简化代码便是:
private void drawTickPath(Canvas canvas) {
mTickPath.reset();
// draw left of the tick
if (mDrewDistance < mLeftLineDistance) {
...
} else {
...
}
// invalidate
if (mDrewDistance < mLeftLineDistance + mRightLineDistance) {
postDelayed(new Runnable() {
@Override
public void run() {
postInvalidate();
}
}, 10);
}
}
已绘制的长度 mDrewDistance < 左边对勾的长度时绘制左边, 否则绘制右边,只要 mDrewDistance 一直小于对勾的总长度,那么便每 10ms 继续重绘。
接着,知道了绘制的逻辑,就来看看绘制对勾的细节代码:
绘制左边
if (mDrewDistance < mLeftLineDistance) {
float step = (mWidth / 20.0f) < 3 ? 3 : (mWidth / 20.0f);
mDrewDistance += step;
float stopX = mTickPoints[0].x + (mTickPoints[1].x - mTickPoints[0].x) * mDrewDistance / mLeftLineDistance;
float stopY = mTickPoints[0].y + (mTickPoints[1].y - mTickPoints[0].y) * mDrewDistance / mLeftLineDistance;
mTickPath.moveTo(mTickPoints[0].x, mTickPoints[0].y);
mTickPath.lineTo(stopX, stopY);
canvas.drawPath(mTickPath, mTickPaint);
if (mDrewDistance > mLeftLineDistance) {
mDrewDistance = mLeftLineDistance;
}
}
绘制的长度最小设置为 每 10ms 绘制 3px
float step = (mWidth / 20.0f) < 3 ? 3 : (mWidth / 20.0f);
就计算每一步需要绘制的终点坐标:
float stopX = mTickPoints[0].x + (mTickPoints[1].x - mTickPoints[0].x) * mDrewDistance / mLeftLineDistance;
float stopY = mTickPoints[0].y + (mTickPoints[1].y - mTickPoints[0].y) * mDrewDistance / mLeftLineDistance;
将终点坐标的计算公式,写的明白点其实可以这样写:
float stopX = mTickPoints[0].x + (mDrewDistance / mLeftLineDistance) *(mTickPoints[1].x - mTickPoints[0].x);
float stopY = mTickPoints[0].y + (mDrewDistance / mLeftLineDistance) * (mTickPoints[1].y - mTickPoints[0].y) * ;
描述:计算 已绘制长度 与 需要绘制长度 的比列,分别计算出 相应比例下的 x/y 轴的坐标点。
绘制右边
else {
mTickPath.moveTo(mTickPoints[0].x, mTickPoints[0].y);
mTickPath.lineTo(mTickPoints[1].x, mTickPoints[1].y);
canvas.drawPath(mTickPath, mTickPaint);
// draw right of the tick
if (mDrewDistance < mLeftLineDistance + mRightLineDistance) {
float stopX = mTickPoints[1].x + (mTickPoints[2].x - mTickPoints[1].x) * (mDrewDistance - mLeftLineDistance) / mRightLineDistance;
float stopY = mTickPoints[1].y - (mTickPoints[1].y - mTickPoints[2].y) * (mDrewDistance - mLeftLineDistance) / mRightLineDistance;
mTickPath.reset();
mTickPath.moveTo(mTickPoints[1].x, mTickPoints[1].y);
mTickPath.lineTo(stopX, stopY);
canvas.drawPath(mTickPath, mTickPaint);
float step = (mWidth / 20) < 3 ? 3 : (mWidth / 20);
mDrewDistance += step;
} else {
mTickPath.reset();
mTickPath.moveTo(mTickPoints[1].x, mTickPoints[1].y);
mTickPath.lineTo(mTickPoints[2].x, mTickPoints[2].y);
canvas.drawPath(mTickPath, mTickPaint);
}
}
其实绘制右边只是别左边多了些步骤而已,比如先绘制左边,比如绘制超出的判断。
其它函数
一个自定义控件的完成,不仅仅只是绘制,正如前面几篇我绘制的一些工作中会用到的比较简单的控件,往往需要考虑到实际使用的情况,提供简便的方法,SmoothCheckBox 的其它函数就是为此而生:
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putParcelable(KEY_INSTANCE_STATE, super.onSaveInstanceState());
bundle.putBoolean(KEY_INSTANCE_STATE, isChecked());
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle bundle = (Bundle) state;
boolean isChecked = bundle.getBoolean(KEY_INSTANCE_STATE);
setChecked(isChecked);
super.onRestoreInstanceState(bundle.getParcelable(KEY_INSTANCE_STATE));
return;
}
super.onRestoreInstanceState(state);
}
@Override
public boolean isChecked() {
return mChecked;
}
@Override
public void toggle() {
this.setChecked(!isChecked());
}
@Override
public void setChecked(boolean checked) {
mChecked = checked;
reset();
invalidate();
if (mListener != null) {
mListener.onCheckedChanged(SmoothCheckBox.this, mChecked);
}
}
/**
* checked with animation
* @param checked checked
* @param animate change with animation
*/
public void setChecked(boolean checked, boolean animate) {
if (animate) {
mTickDrawing = false;
mChecked = checked;
mDrewDistance = 0f;
if (checked) {
startCheckedAnimation();
} else {
startUnCheckedAnimation();
}
if (mListener != null) {
mListener.onCheckedChanged(SmoothCheckBox.this, mChecked);
}
} else {
this.setChecked(checked);
}
}
总结
总的来说这个控件并不是很难,知识点涵盖到了:属性动画,drawCircle,drawPath 的相关知识,不过要想实现这个效果,需要有大量的思考才行,就好象 startCheckedAnimation 中,第一个属性动画的时间:mDuration /3 *2, 我想这是作者测试了很多得出来的最好效果,到此这个控件的分析也就结束了,全部代码也只有 400+ ,感谢作者提供的便利,最后再给个链接 SmoothCheckBox。