前言
我們都知道,平時表現進度的方式有千千萬萬種(沒有UI想不到的,只有你做不到的= =.),其中有一種就是水波紋進度球的形式,網上很多種實現都是直接采用純色填充的方式,即水波紋都是純顏色填充,效果看起來都挺不錯,例如下面的效果:
突發奇想,如果不止滿足于純色的水波紋呢?能不能通過設置一個圖標,來做出同樣的效果呢?原理差不多,只是多了些細節處理,先上效果圖:
?
實現
思路
將水波紋的內容替換成了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();
兩段水波紋繪制完畢,效果如下:
?
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等等,不同的模式有不同的效果,通俗點講就是當兩個圖層疊在一起并且它們之間有交集的時候,通過設置不同的混合模式能夠結合成不同的效果,比如說只顯示相交部分的源圖像,或者只顯示相交部分的目標圖像,各種模式的效果例圖如下:
而我們這個自定義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.save
和canvas.restore
放到離屏緩存中進行。
可以看到,我們這里先繪制的是Icon,所以我們的Icon是目標圖像(SRC),水波紋曲線和圓形遮罩部分則是源圖像(DST),從上面圖解中也可以看出,DST_IN模式下,相交區域為顯示DST的圖像,也就是會在我們的圓形和波浪圖中顯示我們的Icon,這里拿了Keep的logo做素材:
?
結語
上面已經實現了主體的效果,支持對圖標做進度球效果,同時也添加了對純色調的波浪球效果的定制,可以設置動畫時長球體顏色等等,對一些細節做了優化處理,完整代碼已上傳到 自定義組件庫,歡迎提出優化意見。
?
歡迎關注 Android小Y 的簡書,更多Android精選自定義View
Android 玩轉PathMeasure之自定義支付結果動畫
Android 自定義弧形旋轉菜單欄——衛星菜單
Android 自定義帶入場動畫的弧形百分比進度條
GitHub:GitHub-ZJYWidget
CSDN博客:IT_ZJYANG
簡 書:Android小Y
在 GitHub 上建了一個集合炫酷自定義View的項目,里面有很多實用的自定義View源碼及demo,會長期維護,歡迎Star~ 如有不足之處或建議還望指正,相互學習,相互進步,如果覺得不錯動動小手點個喜歡, 謝謝~