『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 技術小棧,更多精彩原創
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容