周一上午,老王叼着包子,一进公司门就看到了产品在张望。老王暗道一声不好,正想溜之大吉,却已经被产品半路截下:“我们的进度条需要改版,我给你找了个样式,你就照这个做吧”。老王寻思不就一进度条吗,能有什么花头。伸手接过产品递来的手机一看,眼前的进度条长这个样子:
老王眉头一皱,发现事情并不简单,但是作为一个沉着冷静的老开发,他从来不会说“应该”、“或许”这种有损他形象的词语。他转头对产品微微一笑,说道:“下班前给你”。
.......
一、进度条的背景与填充
看到这么一个需求,开发的第一反应就是先实现外部的背景与填充色。首先绘制外部的整体背景,然后根据进度绘制内部的填充。那么首先需要实现这样一个自定义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);
}
}
运行一下,发现效果并没有什么变化。这是PorterDuffXfermode的第一个坑:
只有在绘制Bitmap时使用PorterDuffXfermode才会生效,Dst和Src都应该通过Bitmap构建。
public class ProgressTestView extends View {
// ......
private PorterDuffXfermode mPorterDuffXfermode;
private Bitmap mDstBitmap;
private Canvas mDstCanvas;
private Bitmap mSrcBitmap;
private Canvas mSrcCanvas;
// ......
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
// ......
mDstBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
mDstCanvas = new Canvas(mDstBitmap);
mSrcBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
mSrcCanvas = new Canvas(mSrcBitmap);
}
private void drawBackground(Canvas canvas) {
canvas.drawRoundRect(mBackgroundRectF, mRadius, mRadius, mBackgroundPaint);
}
private void drawContent(Canvas canvas) {
mDstCanvas.drawRoundRect(new RectF(0, 0, mCurProgress * mWidth, mHeight),
mRadius, mRadius, mContentPaint);
canvas.drawBitmap(mDstBitmap, 0, 0, mContentPaint);
mContentPaint.setXfermode(mPorterDuffXfermode);
mSrcCanvas.drawRoundRect(mBackgroundRectF, mRadius, mRadius, mContentPaint);
canvas.drawBitmap(mSrcBitmap, 0, 0, mContentPaint);
mContentPaint.setXfermode(null);
}
// ......
}
当老王用这个代码信心满满地run起这段代码时,发现手机屏幕上一片空白,此时老王的脑海里也是一片空白:恩?我的进度条呢?
不要怀疑人生,这是PorterDuffXfermode的又一个深坑:当使用SRC_IN以及DST_IN这两个模式的时候,需要在Bitmap的画布绘制完成之后再去设置合成模式,简单来说,drawContent(Canvas canvas)
方法改成下面这样就行了。
private void drawContent(Canvas canvas) {
mDstCanvas.drawRoundRect(new RectF(0, 0, mCurProgress * mWidth, mHeight),
mRadius, mRadius, mContentPaint);
canvas.drawBitmap(mDstBitmap, 0, 0, mContentPaint);
mSrcCanvas.drawRoundRect(mBackgroundRectF, mRadius, mRadius, mContentPaint);
mContentPaint.setXfermode(mPorterDuffXfermode);
canvas.drawBitmap(mSrcBitmap, 0, 0, mContentPaint);
mContentPaint.setXfermode(null);
}
再来看看效果。三个字:舒服了~
三、文字绘制
有了上面的成功经验,绘制文字的思路就很清晰了。将文字作为DST,进度条的内容作为SRC,文字本身为白色,当两者相交时,相交部分的文字绘制进度条内容的颜色。查阅一下效果图,很显然这种合成方式是SRC_ATOP。
这里不再单独介绍文字的绘制和基线的计算方式,整个进度条的全部代码如下所示。
public class SaleProgressView extends View {
private Context mContext;
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 Bitmap mDstBitmap;
private Canvas mDstCanvas;
private Bitmap mSrcBitmap;
private Canvas mSrcCanvas;
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);
mContext = context;
init();
}
private void init() {
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
mContentMode = new PorterDuffXfermode(PorterDuff.Mode.DST_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(mContext.getResources().getColor(R.color.color_progress_content));
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);
}
mDstBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
mDstCanvas = new Canvas(mDstBitmap);
mSrcBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
mSrcCanvas = new Canvas(mSrcBitmap);
mTextBitmap = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);
mTextCanvas = new Canvas(mTextBitmap);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawBg(canvas);
drawContent(canvas);
drawText(canvas);
}
private void drawBg(Canvas canvas) {
canvas.drawRoundRect(mRectFBackground, mRadius, mRadius, mBgPaint);
}
private void drawContent(Canvas canvas) {
mDstCanvas.drawRoundRect(new RectF(0, 0,
mWidth * mCurPercentage, mHeight),
mRadius, mRadius, mContentPaint);
canvas.drawBitmap(mDstBitmap, 0, 0, mContentPaint);
mSrcCanvas.drawRoundRect(mRectFBackground, mRadius, mRadius, mContentPaint);
mContentPaint.setXfermode(mContentMode);
canvas.drawBitmap(mSrcBitmap, 0, 0, mContentPaint);
mContentPaint.setXfermode(null);
}
private void drawText(Canvas canvas) {
String text = "下载中";
mTextPaint.setColor(mContext.getResources().getColor(R.color.color_progress_content));
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);
}
}
......
当老王把这个需求完成的时候,天已经蒙蒙亮了,老王潇洒地把效果展示给了打着哈欠刚来上班的产品。产品看完以后疑惑地问:“你昨天不是说下班前给我吗?”,老王微微一笑:“我还没下班呢~”
四、参考
- PorterDuffXferMode不正确的真正原因PorterDuffXferMode深入试验
- 各个击破搞明白PorterDuff.Mode
PS
2019.08.13更新:
评论区有小伙伴提到,用进度条显示进度值时,绘制出来的文字会重叠在一起。这是因为绘制文字一直用的是mTextBitmap
的画布。在对文字进行修改的时候,多次绘制的文字都会出现在该画布上,最终出现重叠的现象。针对这个问题,可以将onSizeChanged()
中的
mTextBitmap = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);
mTextCanvas = new Canvas(mTextBitmap);
放到drawText()
中,每次绘制文字都使用新的画布就行了。
由于onDraw()
方法在View
的绘制过程中会被调用很多次,如果每次都新建Bitmap
和Canvas
,对于内存回收来说是一个负担。再仔细想想,其实我们只需要在文字修改时new
新的Bitmap
即可,因此将上述代码放到View
中设置显示文字的方法中是最好的。