Android - PorterDuffXfermode实现进度条

周一上午,老王叼着包子,一进公司门就看到了产品在张望。老王暗道一声不好,正想溜之大吉,却已经被产品半路截下:“我们的进度条需要改版,我给你找了个样式,你就照这个做吧”。老王寻思不就一进度条吗,能有什么花头。伸手接过产品递来的手机一看,眼前的进度条长这个样子:

Android - PorterDuffXfermode实现进度条_第1张图片
gif_效果.gif

老王眉头一皱,发现事情并不简单,但是作为一个沉着冷静的老开发,他从来不会说“应该”、“或许”这种有损他形象的词语。他转头对产品微微一笑,说道:“下班前给你”。
.......

一、进度条的背景与填充

看到这么一个需求,开发的第一反应就是先实现外部的背景与填充色。首先绘制外部的整体背景,然后根据进度绘制内部的填充。那么首先需要实现这样一个自定义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设置的高度和宽度,由于需要绘制的是一个圆角矩形,这里将高度的一半设置为圆角矩形两端的半径。此时的效果如下:

进度条背景.png

有了背景之后就可以开始绘制内容了,背景中的填充也是一个圆角矩形。该圆角矩形的高度和弧度与背景矩形相同,宽度是当前进度(∈[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();

最终的效果如下所示:

Android - PorterDuffXfermode实现进度条_第2张图片
gif_直接绘制内容.gif

这个效果是不是有哪里不对?老王也陷入了沉思,作为一个强迫症,根本无法接受这种丑陋的动画。
仔细观察发现,填充的内容只有在宽度>=高度的时候才有正确的显示效果。那么我们可不可以只显示背景与内容相交的部分呢?当然可以!这就涉及到图像合成——PorterDuffXfermode

二、PorterDuffXfermode的使用和避坑指南

PorterDuffXfermode用于两个图像的合成,API中定义了16种合成方式。图中蓝色的为Src图像,黄色的为Dst图像,通过不同的组合方式能够得到各种组合图形。那么要实现之前说的相交,把进度条的内容作为Dst,背景作为Src,通过DstIn这个合成方式不就能得到结果了吗?

Android - PorterDuffXfermode实现进度条_第3张图片
PorterDuffXfermode合成方式.jpg

根据这个思路,再查一下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);
}

再来看看效果。三个字:舒服了~

Android - PorterDuffXfermode实现进度条_第4张图片
gif_图像合成绘制内容.gif

三、文字绘制

有了上面的成功经验,绘制文字的思路就很清晰了。将文字作为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);
    }
}

......

当老王把这个需求完成的时候,天已经蒙蒙亮了,老王潇洒地把效果展示给了打着哈欠刚来上班的产品。产品看完以后疑惑地问:“你昨天不是说下班前给我吗?”,老王微微一笑:“我还没下班呢~”

四、参考

  1. PorterDuffXferMode不正确的真正原因PorterDuffXferMode深入试验
  2. 各个击破搞明白PorterDuff.Mode

PS

2019.08.13更新:
评论区有小伙伴提到,用进度条显示进度值时,绘制出来的文字会重叠在一起。这是因为绘制文字一直用的是mTextBitmap的画布。在对文字进行修改的时候,多次绘制的文字都会出现在该画布上,最终出现重叠的现象。针对这个问题,可以将onSizeChanged()中的

mTextBitmap = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);
mTextCanvas = new Canvas(mTextBitmap);

放到drawText()中,每次绘制文字都使用新的画布就行了。
由于onDraw()方法在View的绘制过程中会被调用很多次,如果每次都新建BitmapCanvas,对于内存回收来说是一个负担。再仔细想想,其实我们只需要在文字修改时new新的Bitmap即可,因此将上述代码放到View中设置显示文字的方法中是最好的。

你可能感兴趣的:(Android - PorterDuffXfermode实现进度条)