『Android自定义View实战』给我一个图标,还你一个水波纹进度球

前言

我们都知道,平时表现进度的方式有千千万万种(没有UI想不到的,只有你做不到的= =.),其中有一种就是水波纹进度球的形式,网上很多种实现都是直接采用纯色填充的方式,即水波纹都是纯颜色填充,效果看起来都挺不错,例如下面的效果:


纯色水波纹球

突发奇想,如果不止满足于纯色的水波纹呢?能不能通过设置一个图标,来做出同样的效果呢?原理差不多,只是多了些细节处理,先上效果图:


图标水波纹进度球.gif

 

实现

思路

将水波纹的内容替换成了Logo,原理上主要也是使用图像合成混合模式以及贝塞尔曲线,结合属性动画而成。从效果图可以看出,绘制的部分主要有三部分:水波纹、图标、球体。主要步骤和实现方式如下:

1.通过贝塞尔曲线绘制出水波纹路径
2.绘制一个圆形Bitmap,用于后面裁剪
3.绘制出目标Icon图
4.通过PorterDuffXfermode,将水波纹路径+圆形Bitmap围起来的图形作为一个遮罩,与Icon图混合显示。

 

1.通过贝塞尔曲线绘制出水波纹路径

关于贝塞尔实现水波纹的效果,详见我另一篇文章 Android 路径绘制艺术——贝塞尔曲线,这里再简单重述一下:

private Path mWavePath1 = new Path();
//每一小节波浪的宽度
private int mItemWidth = 120;
//波浪与View最左边缘之间偏移的距离
private int mOffsetX1;

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    mWavePath1.reset();
    int halfItem = mItemWidth / 2;
    //为了闭合整个View, 否则波浪遮罩顶部显示不正常
    mWavePath1.moveTo(0, 0);
    //必须先减去一个浪的宽度,以便第一遍动画能够刚好位移出一个波浪,形成无限波浪的效果
    mWavePath1.lineTo(-mItemWidth + mOffsetX1, mWaterTop);
    for (int i = mLeft - mItemWidth; i < mLeft + mItemWidth + mWidth; i += mItemWidth) {
        mWavePath1.rQuadTo(halfItem / 2, -mWaveHeight, halfItem, 0);
        mWavePath1.rQuadTo(halfItem / 2, mWaveHeight, halfItem, 0);
    }

    //闭合路径波浪以下区域
    mWavePath1.lineTo(mWidth, mHeight);
    mWavePath1.lineTo(0, mHeight);
    mWavePath1.lineTo(-mItemWidth + mOffsetX, mWaterTop);
    mWavePath1.close();
}

这里一开始将路径起点通过moveTo(0,0)移到左上顶角,是为了让整个波浪曲线的高度能达到整个View的高度,否则最终做动画时水波纹遮罩会无法遮盖水波纹以上的空白区域。
然后主要是通过rQuadTo来绘制出每一小节波浪,然后通过for循环,从View左边缘到View右边缘,连接多节小波浪,形成一个完整的水波纹曲线路径。
然后通过属性动画,不断改变波浪路径的起始点,让其整体视觉上形成动态循环波浪的效果:

ValueAnimator offsetAnimator1 = ValueAnimator.ofInt(0, mItemWidth);
offsetAnimator1.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            mOffsetX1 = (int) animation.getAnimatedValue();
            invalidate();
         }
    });

offsetAnimator1.setInterpolator(new LinearInterpolator());
offsetAnimator1.setDuration(500);
offsetAnimator1.setRepeatCount(-1);
offsetAnimator1.start();

以上就完成了一段水波纹路径的绘制,但是对于我们要的效果还不够,因为单纯一段水波纹看起来有点僵硬,需要再多加另一段水波纹来衬托波浪的随性感:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    mWavePath2.reset();
    int wave2ItemWidth = mItemWidth / 2;
    int halfItem2 = wave2ItemWidth / 2;
    //为了闭合整个View, 否则波浪遮罩顶部显示不正常
    mWavePath2.moveTo(0, 0);
    //必须先减去一个浪的宽度,以便第一遍动画能够刚好位移出一个波浪,形成无限波浪的效果
    mWavePath2.lineTo(-wave2ItemWidth + mOffsetX2, mWaterTop);
    for (int i = mLeft - wave2ItemWidth; i < mLeft + wave2ItemWidth + mWidth; i += wave2ItemWidth) {
        mWavePath2.rQuadTo(halfItem2 / 2, -mWaveHeight / 2, halfItem2, 0);
        mWavePath2.rQuadTo(halfItem2 / 2, mWaveHeight / 2, halfItem2, 0);
    }

    //闭合路径波浪以下区域
    mWavePath2.lineTo(mWidth, mHeight);
    mWavePath2.lineTo(0, mHeight);
    mWavePath2.lineTo(-wave2ItemWidth + mOffsetX2, mWaterTop);
    mWavePath2.close();
}

WavePath2跟WavePath1的不同,在于它的波浪宽改为了一半:int wave2ItemWidth = mItemWidth / 2;,这样这条曲线路径的效果就会稍微比刚才那条紧凑一点(因为每节波浪宽度都变短了),然后我们再在这条波浪的速度上做点手脚:

ValueAnimator offsetAnimator2 = ValueAnimator.ofInt(0, mItemWidth / 2);
mOffsetAnimator2.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        mOffsetX2 = (int) animation.getAnimatedValue();
        invalidate();
    }
});
offsetAnimator2.setInterpolator(new LinearInterpolator());
offsetAnimator2.setDuration(800);
offsetAnimator2.setRepeatCount(-1);
offsetAnimator2.start();

将偏移的时间周期设置成了800,而刚才第一条水波纹曲线的动画周期是500,从而让它们错开。
但是这还不够,我们让它的波浪激烈程度从一开始缓慢到中间加强,最后再变慢,也就是上面代码中的mWaveHeight这个值也通过属性动画动态调整:

mWaveHeightAnim = ValueAnimator.ofInt(0, getHeight() / 2, 0);
mWaveHeightAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        mWaveHeight = (int) animation.getAnimatedValue() / 6;
        invalidate();
    }
});
mWaveHeightAnim.setInterpolator(new AccelerateDecelerateInterpolator());
mWaveHeightAnim.setDuration(5000);

波浪波动的幅度变化过程由0到View的高度/12,再慢慢变回0,并且使用AccelerateDecelerateInterpolator插值器,让它一开始先加速之后慢慢变缓。
最后还有一个,通过属性动画不断调整波浪的起点的纵坐标,从View.getHeight变到0,让它实现水位增高的效果:

ValueAnimator mProgressAnim = ValueAnimator.ofInt(getHeight(), 0);
mProgressAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        mWaterTop = (int) animation.getAnimatedValue();
        invalidate();
    }
});

mProgressAnim.setInterpolator(new AccelerateDecelerateInterpolator());
mProgressAnim.setDuration(5000);
mProgressAnim.start();

两段水波纹绘制完毕,效果如下:


双重水波纹曲线.gif

 

2.创建一个圆形Bitmap,用于后面裁剪

这个圆形最终是要用来做遮罩效果所用的,因此这个Bitmap的宽高肯定要填充我们的自定义View,然后我们在该Bitmap的画布上再以中心为圆点,绘制出一个圆。这里创建一个圆形Bitmap,只是为了下一步裁剪显示圆形部分所用,最终这个Bitmap本身是没啥视觉效果的。

private Bitmap mBallBitmap;
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    mLeft = left;
    mTop = top;
    mRight = right;
    mBottom = bottom;

    mWidth = mRight - mLeft;
    mHeight = mBottom - mTop;

    mBallBitmap = Bitmap.createBitmap((int) mWidth, (int) mHeight, Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(mBallBitmap);
    canvas.drawCircle(mWidth / 2f, mHeight / 2f, mWidth / 2f - mBallStrokeWidth * 3f / 2f, mIconPaint);
}

 

3.绘制目标Icon

通过上面两步我们绘制好了波浪线,创建了圆形遮罩,也就是相当于遮罩层的东西我们已经绘制好了,接下来就先把我们的目标Icon绘制出来:

private Drawable mDrawable;
mDrawable = getResources().getDrawable(R.drawable.ic_keep);
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    mDrawable.setBounds(0, 0, (int) mWidth, (int) mHeight);
}

//绘制Icon
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    mDrawable.draw(canvas);
}

这里采用Drawable的形式,同样是将其设置为View的宽高大小。
 

4.通过混合模式裁剪

准备好了遮罩和源图,那接下来就可以通过PorterDuffXfermode来混合它们了,PorterDuffXfermode类主要用于图形合成时的图像过渡模式计算,它初始化的时候会传入一个混合模式类型,类似如下:

PorterDuffXfermode duffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_IN);

这个PorterDuff.Mode中还有很多模式可供选择,比如说DST_OUT、SRC_IN等等,不同的模式有不同的效果,通俗点讲就是当两个图层叠在一起并且它们之间有交集的时候,通过设置不同的混合模式能够结合成不同的效果,比如说只显示相交部分的源图像,或者只显示相交部分的目标图像,各种模式的效果例图如下:

PorterDuffXfermode模式图解

而我们这个自定义View所需要的场景就是:只显示波浪曲线(步骤1)和圆形遮罩(步骤2)围起来的那部分区域的Icon图像(步骤3),那么我们就在刚才的onDraw中,将圆形遮罩和波浪曲线在混合模式下进行绘制:

PorterDuffXfermode mDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_IN);

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    int layerId = canvas.saveLayer(0, 0, mWidth, mHeight, null, Canvas.ALL_SAVE_FLAG);
    //绘制Icon
    mDrawable.draw(canvas);
    mIconPaint.setXfermode(mDuffXfermode);
    //绘制水波纹1
    canvas.drawPath(mWavePath1, mIconPaint);
    //绘制水波纹2
    canvas.drawPath(mWavePath2, mIconPaint);
    //绘制圆形位图
    canvas.drawBitmap(mBallBitmap, 0, 0, mIconPaint);
    mIconPaint.setXfermode(null);
    canvas.restoreToCount(layerId);
}

采用DST_IN模式,这种模式下,会在两者相交的地方绘制目标图像,并且绘制的是这块区域里的目标图像。由于图像合成是很昂贵的操作,将用到硬件加速,这里将图像合成的处理通过canvas.savecanvas.restore放到离屏缓存中进行。
可以看到,我们这里先绘制的是Icon,所以我们的Icon是目标图像(SRC),水波纹曲线和圆形遮罩部分则是源图像(DST),从上面图解中也可以看出,DST_IN模式下,相交区域为显示DST的图像,也就是会在我们的圆形和波浪图中显示我们的Icon,这里拿了Keep的logo做素材:

波浪进度球.gif

 

结语

上面已经实现了主体的效果,支持对图标做进度球效果,同时也添加了对纯色调的波浪球效果的定制,可以设置动画时长球体颜色等等,对一些细节做了优化处理,完整代码已上传到 自定义组件库,欢迎提出优化意见。
 

欢迎关注 Android小Y 的简书,更多Android精选自定义View

Android 玩转PathMeasure之自定义支付结果动画
Android 自定义弧形旋转菜单栏——卫星菜单
Android 自定义带入场动画的弧形百分比进度条

GitHubGitHub-ZJYWidget
CSDN博客IT_ZJYANG
简 书Android小Y
GitHub 上建了一个集合炫酷自定义View的项目,里面有很多实用的自定义View源码及demo,会长期维护,欢迎Star~ 如有不足之处或建议还望指正,相互学习,相互进步,如果觉得不错动动小手点个喜欢, 谢谢~

关注Android 技术小栈,更多精彩原创
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容