浪起來!使用 drawBitmapMesh 實現仿真水波紋效果

在 Android 的畫布 Canvas 裏面有個 drawBitmapMesh 方法,經過它能夠實現對 Bitmap 的各類扭曲。咱們試一下用它把圖像扭出水波紋的效果。java

和 Material Design 裏扁平化的水波紋不一樣,這裏是經過對圖像的處理,模擬真實的水波紋效果,最後實現的效果以下:git

drawBitmapMesh 簡介

咱們先了解一下「網格」的概念。github

將一個圖片橫向、縱向均勻切割成 n 份,就會造成一個「網格」,我把全部網格線的交點稱爲「頂點」。算法

正常狀況下,頂點是均勻分佈的。當咱們改變了頂點的位置時,系統會拿偏移後的頂點座標,和原來的座標進行對比,經過一套算法,將圖片進行扭曲,像這樣:canvas

接下來看看 drawBitmapMesh 方法:數組

public void drawBitmapMesh(Bitmap bitmap, int meshWidth, int meshHeight, float[] verts, int vertOffset, int[] colors, int colorOffset, Paint paint)複製代碼

它的參數以下:緩存

  • bitmap - 須要轉換的位圖
  • meshWidth - 橫向的格數,需大於 0
  • meshHeight - 縱向的格數,需大於 0
  • verts - 網格頂點座標數組,記錄扭曲後圖片各頂點的座標,數組大小爲 (meshWidth+1) (meshHeight+1) 2 + vertOffset
  • vertOffset - 從第幾個頂點開始對位圖進行扭曲,一般傳 0
  • colors - 設置網格頂點的顏色,該顏色會和位圖對應像素的顏色疊加,數組大小爲 (meshWidth+1) * (meshHeight+1) + colorOffset,能夠傳 null
  • colorOffset - 從第幾個頂點開始轉換顏色,一般傳 0
  • paint - 「畫筆」,能夠傳 null

須要說明一下的是,能夠用 colors 這個參數來實現陰影的效果,但在 API 18 如下開啓了硬件加速,colors 這個參數是不起做用的。咱們這裏只關注前面四個參數,後面四個傳 0、null、0、null 就能夠了。ide

建立 RippleLayout

建立自定義控件 RippleLayout,爲了讓控件用起來更靈活,我讓它繼承了 FrameLayout(套上哪一個哪一個浪!)。函數

定義了以下成員變量:動畫

//圖片橫向、縱向的格數
private final int MESH_WIDTH = 20;
private final int MESH_HEIGHT = 20;
//圖片的頂點數
private final int VERTS_COUNT = (MESH_WIDTH + 1) * (MESH_HEIGHT + 1);
//原座標數組
private final float[] staticVerts = new float[VERTS_COUNT * 2];
//轉換後的座標數組
private final float[] targetVerts = new float[VERTS_COUNT * 2];
//當前控件的圖片
private Bitmap bitmap;
//水波寬度的一半
private float rippleWidth = 100f;
//水波擴散速度
private float rippleSpeed = 15f;
//水波半徑
private float rippleRadius;
//水波動畫是否執行中
private boolean isRippling;複製代碼

看註釋就知道什麼意思啦,下面會用到的。

而後又定義了一個這裏會常常用到的方法,根據寬高計算對角線的距離(勾股定理):

/** * 根據寬高,獲取對角線距離 * * @param width 寬 * @param height 高 * @return 距離 */
private float getLength(float width, float height) {
    return (float) Math.sqrt(width * width + height * height);
}複製代碼

獲取 Bitmap

要處理 Bitmap,第一步固然是先拿到 Bitmap,拿到後就能夠根據 Bitmap 的寬高初始化兩個頂點座標數組:

/** * 初始化 Bitmap 及對應數組 */
private void initData() {
    bitmap = getCacheBitmapFromView(this);
    if (bitmap == null) {
        return;
    }
    float bitmapWidth = bitmap.getWidth();
    float bitmapHeight = bitmap.getHeight();
    int index = 0;
    for (int height = 0; height <= MESH_HEIGHT; height++) {
        float y = bitmapHeight * height / MESH_HEIGHT;
        for (int width = 0; width <= MESH_WIDTH; width++) {
            float x = bitmapWidth * width / MESH_WIDTH;
            staticVerts[index * 2] = targetVerts[index * 2] = x;
            staticVerts[index * 2 + 1] = targetVerts[index * 2 + 1] = y;
            index += 1;
        }
    }
}

/** * 獲取 View 的緩存視圖 * * @param view 對應的View * @return 對應View的緩存視圖 */
private Bitmap getCacheBitmapFromView(View view) {
    view.setDrawingCacheEnabled(true);
    view.buildDrawingCache(true);
    final Bitmap drawingCache = view.getDrawingCache();
    Bitmap bitmap;
    if (drawingCache != null) {
        bitmap = Bitmap.createBitmap(drawingCache);
        view.setDrawingCacheEnabled(false);
    } else {
        bitmap = null;
    }
    return bitmap;
}複製代碼

計算偏移座標

接下來是重點了。這裏要實現的水波的位置在下圖的灰色區域:

我定義了一個 warp 方法,根據手指按下的座標(原點)來重繪 Bitmap:

/** * 圖片轉換 * * @param originX 原點 x 座標 * @param originY 原點 y 座標 */
private void warp(float originX, float originY) {
    for (int i = 0; i < VERTS_COUNT * 2; i += 2) {
        float staticX = staticVerts[i];
        float staticY = staticVerts[i + 1];
        float length = getLength(staticX - originX, staticY - originY);
        if (length > rippleRadius - rippleWidth && length < rippleRadius + rippleWidth) {
            PointF point = getRipplePoint(originX, originY, staticX, staticY);
            targetVerts[i] = point.x;
            targetVerts[i + 1] = point.y;
        } else {
            //復原
            targetVerts[i] = staticVerts[i];
            targetVerts[i + 1] = staticVerts[i + 1];
        }
    }
    invalidate();
}複製代碼

方法裏面遍歷了全部的頂點,若是頂點是在水波範圍內,則須要對這個頂點進行偏移。

偏移後的座標計算,思路大概是這樣的:

爲了讓水波有突起的感受,以水波中間(波峯)爲分界線,裏面的頂點往裏偏移,外面的頂點往外偏移:

至於偏移的距離,我想要實現相似放大鏡的效果,離波峯越近的頂點,偏移的距離會越大。離波峯的距離和偏移距離的關係,能夠看做一個餘弦曲線:

咱們來看一下 getRipplePoint 方法,傳入的參數是原點的座標及須要轉換的頂點座標,在它裏面作了下面這些處理:

  1. 經過反正切函數獲取到頂點和原點間的水平角度:

float angle = (float) Math.atan(Math.abs((staticY - originY) / (staticX - originX)));複製代碼
  1. 經過餘弦函數計算頂點的偏移距離:
float length = getLength(staticX - originX, staticY - originY);
float rate = (length - rippleRadius) / rippleWidth;
float offset = (float) Math.cos(rate) * 10f;複製代碼

這裏的 10f 是最大偏移距離。

  1. 計算出來的偏移距離是直線距離,還須要根據頂點和原點的角度,用餘弦、正弦函數將它轉換成水平、豎直方向的偏移距離:
float offsetX = offset * (float) Math.cos(angle);
float offsetY = offset * (float) Math.sin(angle);複製代碼
  1. 根據頂點原來的座標和偏移量就能夠得出偏移後的座標了,至因而加仍是減,還要看頂點所在的位置。

getRipplePoint 的完整代碼以下:

/** * 獲取水波的偏移座標 * * @param originX 原點 x 座標 * @param originY 原點 y 座標 * @param staticX 待偏移頂點的原 x 座標 * @param staticY 待偏移頂點的原 y 座標 * @return 偏移後坐標 */
private PointF getRipplePoint(float originX, float originY, float staticX, float staticY) {
    float length = getLength(staticX - originX, staticY - originY);
    //偏移點與原點間的角度
    float angle = (float) Math.atan(Math.abs((staticY - originY) / (staticX - originX)));
    //計算偏移距離
    float rate = (length - rippleRadius) / rippleWidth;
    float offset = (float) Math.cos(rate) * 10f;
    float offsetX = offset * (float) Math.cos(angle);
    float offsetY = offset * (float) Math.sin(angle);
    //計算偏移後的座標
    float targetX;
    float targetY;
    if (length < rippleRadius + rippleWidth && length > rippleRadius) {
        //波峯外的偏移座標
        if (staticX > originY) {
            targetX = staticX + offsetX;
        } else {
            targetX = staticX - offsetX;
        }
        if (staticY > originY) {
            targetY = staticY + offsetY;
        } else {
            targetY = staticY - offsetY;
        }
    } else {
        //波峯內的偏移座標
        if (staticX > originY) {
            targetX = staticX - offsetX;
        } else {
            targetX = staticX + offsetX;
        }
        if (staticY > originY) {
            targetY = staticY - offsetY;
        } else {
            targetY = staticY + offsetY;
        }
    }
    return new PointF(targetX, targetY);
}複製代碼

我也不知道這種計算方法是否符合物理規律,反正感受像那麼回事。

執行水波動畫

你們都知道事件分發機制,做爲一個 ViewGroup,會先執行 dispatchTouchEvent 方法。我在事件分發以前執行水波動畫,也保證了事件傳遞不受影響:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            showRipple(ev.getX(), ev.getY());
            break;
    }
    return super.dispatchTouchEvent(ev);
}複製代碼

showRipple 的任務就是循環執行 warp 方法,而且不斷改變水波半徑,達到向外擴散的效果:

/** * 顯示水波動畫 * * @param originX 原點 x 座標 * @param originY 原點 y 座標 */
public void showRipple(final float originX, final float originY) {
    if (isRippling) {
        return;
    }
    initData();
    if (bitmap == null) {
        return;
    }
    isRippling = true;
    //循環次數,經過控件對角線距離計算,確保水波紋徹底消失
    int viewLength = (int) getLength(bitmap.getWidth(), bitmap.getHeight());
    final int count = (int) ((viewLength + rippleWidth) / rippleSpeed);
    Observable.interval(0, 10, TimeUnit.MILLISECONDS)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .take(count + 1)
            .subscribe(new Consumer<Long>() {
                @Override
                public void accept(@NonNull Long aLong) throws Exception {
                    rippleRadius = aLong * rippleSpeed;
                    warp(originX, originY);
                    if (aLong == count) {
                        isRippling = false;
                    }
                }
            });
}複製代碼

這裏用了 RxJava 2 實現循環,循環的次數是根據控件的對角線計算的,保證水波會徹底消失。水波消失後再點擊纔會執行下一次的水波動畫。

注意!要點題了。

講了這麼多還沒用到 drawBitmapMesh 方法。ViewGroup 繪製子控件的方法是 dispatchDraw,warp 方法最後調用的 invalidate() 也會觸發 dispatchDraw 的執行,因此能夠在這裏作手腳:

@Override
protected void dispatchDraw(Canvas canvas) {
    if (isRippling && bitmap != null) {
        canvas.drawBitmapMesh(bitmap, MESH_WIDTH, MESH_HEIGHT, targetVerts, 0, null, 0, null);
    } else {
        super.dispatchDraw(canvas);
    }
}複製代碼

若是是自定義 View 的話,要修改 onDraw 方法。

到這就完成啦。妥妥的。

對了,不建議用這個控件包裹可滑動或者有動畫的控件,由於在繪製水波的時候,子控件的變化都是看不到的。

源碼地址

相關文章
相關標籤/搜索