周一上午,老王叼着包子,一进公司门就看到了产品在张望。老王暗道一声不好,正想溜之大吉,却已经被产品半路截下:“我们的进度条需要改版,我给你找了个样式,你就照这个做吧”。老王寻思不就一进度条吗,能有什么花头。伸手接过产品递来的手机一看,眼前的进度条长这个样子:
老王眉头一皱,发现事情并不简单,但是作为一个沉着冷静的老开发,他从来不会说“应该”、“或许”这种有损他形象的词语。他转头对产品微微一笑,说道:“下班前给你”。
.......
一、进度条的背景与填充
看到这么一个需求,老王的第一反应就是先实现外部的背景与填充色。首先绘制外部的整体背景,然后根据进度绘制内部的填充。那么首先需要实现这样一个自定义View:
public class ProgressTestView extends View {
private Context mContext;
private int mWidth;
private int mHeight;
private float mRadius;
private Paint mBackgroundPaint;
private RectF mBackgroundRectF;
public ProgressTestView(Context context) {
this(context, null);
}
public ProgressTestView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public ProgressTestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mContext = context;
init();
}
private void init() {
setLayerType(LAYER_TYPE_SOFTWARE, null); // 取消硬件加速
mBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBackgroundPaint.setColor(Color.GRAY);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWidth = getMeasuredWidth();
mHeight = getMeasuredHeight();
mRadius = mHeight / 2.0f;
mBackgroundRectF = new RectF(0, 0, mWidth, mHeight);
}
private void drawBackground(Canvas canvas) {
canvas.drawRoundRect(mBackgroundRectF, mRadius, mRadius, mBackgroundPaint);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawBackground(canvas);
}
}
这个自定义View很简单,在onSizeChanged()
中获取到为该View设置的高度和宽度,由于需要绘制的是一个圆角矩形,这里将高度的一半设置为圆角矩形两端的半径。此时的效果如下:
有了背景之后就可以开始绘制内容了,背景中的填充也是一个圆角矩形。该圆角矩形的高度和弧度与背景矩形相同,宽度是当前进度(∈[0, 1])与背景宽度的乘积。根据该思路,需要定义一个值表示当前的进度并为外界提供修改该进度的方法。当外界每次改变进度时重绘当前View。代码如下:
public class ProgressTestView extends View {
// ....
private float mCurProgress = 0;
private Paint mContentPaint;
// ......
public void setCurProgress(float progress) {
if (progress > 1) mCurProgress = 1;
else if (progress < 0) mCurProgress = 0;
else mCurProgress = progress;
invalidate();
}
private void init() {
// ......
mContentPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mContentPaint.setColor(mContext.getResources().getColor(R.color.color_progress_content));
}
// ......
private void drawContent(Canvas canvas) {
canvas.drawRoundRect(new RectF(0, 0, mCurProgress * mWidth, mHeight),
mRadius, mRadius, mContentPaint);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawBackground(canvas);
drawContent(canvas);
}
}
然后在使用该View的地方定义一个动画看看效果:
ValueAnimator animator = ValueAnimator.ofFloat(0, 1);
animator.setDuration(5000);
animator.addUpdateListener(animation -> {
float percentage = (float) animation.getAnimatedValue();
mProgressView.setCurProgress(percentage);
});
animator.start();
最终的效果如下所示:
这个效果是不是有哪里不对?老王也陷入了沉思,作为一个强迫症,根本无法接受这种丑陋的动画。
仔细观察发现,填充的内容只有在宽度>=高度的时候才有正确的显示效果。那么我们可不可以只显示背景与内容相交的部分呢?当然可以!这就涉及到图像合成——PorterDuffXfermode。
二、PorterDuffXfermode的使用和避坑指南
PorterDuffXfermode用于两个图像的合成,API中定义了16种合成方式。图中蓝色的为Src图像,黄色的为Dst图像,通过不同的组合方式能够得到各种组合图形。那么要实现之前说的相交,把进度条的内容作为Dst,背景作为Src,通过DstIn这个合成方式不就能得到结果了吗?
根据这个思路,再查一下PorterDuffXfermode的使用文档,就可以把原来的代码修改成下面这样。注意绘制时是先DST后SRC。
public class ProgressTestView extends View {
private PorterDuffXfermode mPorterDuffXfermode;
// ......
private void init() {
setLayerType(LAYER_TYPE_SOFTWARE, null);
mPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_IN);
mBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBackgroundPaint.setColor(Color.GRAY);
mContentPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mContentPaint.setColor(mContext.getResources().getColor(R.color.color_progress_content));
}
// ......
private void drawContent(Canvas canvas) {
canvas.drawRoundRect(new RectF(0, 0, mCurProgress * mWidth, mHeight),
mRadius, mRadius, mContentPaint);
mContentPaint.setXfermode(mPorterDuffXfermode);
canvas.drawRoundRect(mBackgroundRectF, mRadius, mRadius, mContentPaint);
mContentPaint.setXfermode(null);
}
}
运行一下,发现效果并没有什么变化,这是因为这里没有考虑到图层(Layer)的问题。
Layer可以理解为画布Canvas的一个层级,默认情况下Canvas只有一个Layer,所有的绘制都在同一图层上。当需要绘制多层图像时,可以通过canvas.saveLayer(...)
生成新的Layer,在新Layer上绘制的内容是独立的,不会影响到其他Layer的内容,调用canvas. restoreToCount(int sc)
时将该Layer覆盖到Canvas现有的图像上。Canvas通过栈的形式管理Layer,示意图如下。
而在进行图像混合时,先绘制的内容是DST,后绘制的是SRC。如果不新建Layer的话,在绘制SRC时,Canvas上的所有内容都会被当作DST,所以背景等内容也会参与图像混合,很容易得到错误的效果。
public class ProgressTestView extends View {
// ......
private void drawContent(Canvas canvas) {
int sc = canvas.saveLayer(0, 0, mWidth, mHeight, null);
canvas.drawRoundRect(mRectFBackground, mRadius, mRadius, mBgPaint);
mContentPaint.setXfermode(mContentMode);
canvas.drawRoundRect(new RectF(0, 0, mWidth * mCurPercentage, mHeight),
mRadius, mRadius, mContentPaint);
mContentPaint.setXfermode(null);
canvas.restoreToCount(sc);
}
// ......
}
再来看看效果。三个字:舒服了~
三、文字绘制
有了上面的成功经验,绘制文字的思路就很清晰了。将文字作为DST,进度条的内容作为SRC,文字本身为白色,当两者相交时,相交部分的文字绘制进度条内容的颜色。查阅一下效果图,很显然这种合成方式是SRC_ATOP。这里不再单独介绍文字的绘制和基线的计算方式,整个进度条的全部代码如下所示。
public class SaleProgressView extends View {
private int mWidth;
private int mHeight;
private float mRadius;
private RectF mRectFBackground;
private Paint mBgPaint;
private Paint mContentPaint;
private Paint mTextPaint;
private float mBaseLineY;
private PorterDuffXfermode mContentMode;
private PorterDuffXfermode mTextMode;
private Bitmap mTextBitmap;
private Canvas mTextCanvas;
private float mCurPercentage;
public SaleProgressView(Context context) {
this(context, null);
}
public SaleProgressView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SaleProgressView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
mContentMode = new PorterDuffXfermode(PorterDuff.Mode.SRC_IN);
mTextMode = new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP);
mBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBgPaint.setColor(Color.GRAY);
mContentPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mContentPaint.setColor(Color.GREEN);
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setTextSize(36);
mTextPaint.setTextAlign(Paint.Align.CENTER);
}
public void setCurPercentage(float curPercentage) {
mCurPercentage = curPercentage;
invalidate();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWidth = getMeasuredWidth();
mHeight = getMeasuredHeight();
// 圆的半径
mRadius = mHeight / 2.0f;
if (mRectFBackground == null) {
mRectFBackground = new RectF(0, 0,
mWidth, mHeight);
}
if (mBaseLineY == 0.0f) {
Paint.FontMetricsInt fm = mTextPaint.getFontMetricsInt();
mBaseLineY = mHeight / 2.0f - (fm.descent / 2.0f + fm.ascent / 2.0f);
}
mTextBitmap = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);
mTextCanvas = new Canvas(mTextBitmap);
}
@Override
protected void onDraw(Canvas canvas) {
drawContent(canvas);
drawText(canvas);
}
private void drawContent(Canvas canvas) {
int sc = canvas.saveLayer(0, 0, mWidth, mHeight, null);
canvas.drawRoundRect(mRectFBackground, mRadius, mRadius, mBgPaint);
mContentPaint.setXfermode(mContentMode);
canvas.drawRoundRect(new RectF(0, 0, mWidth * mCurPercentage, mHeight),
mRadius, mRadius, mContentPaint);
mContentPaint.setXfermode(null);
canvas.restoreToCount(sc);
}
private void drawText(Canvas canvas) {
String text = "下载中";
mTextPaint.setColor(Color.GREEN);
mTextCanvas.drawText(text, mWidth / 2.0f, mBaseLineY, mTextPaint);
mTextPaint.setXfermode(mTextMode);
mTextPaint.setColor(Color.WHITE);
mTextCanvas.drawRoundRect(new RectF(0, 0, mWidth * mCurPercentage, mHeight),
mRadius, mRadius, mTextPaint);
canvas.drawBitmap(mTextBitmap, 0, 0, null);
mTextPaint.setXfermode(null);
}
}
......
当老王把这个需求完成的时候,天已经蒙蒙亮了,老王潇洒地把效果展示给了打着哈欠刚来上班的产品。产品看完以后疑惑地问:“你昨天不是说下班前给我吗?”,老王微微一笑:“我还没下班呢~”