浪起來!使用 drawBitmapMesh 實現模擬水波紋效果

SouthernBox發表於2019-02-28

在 Android 的畫布 Canvas 裡面有個 drawBitmapMesh 方法,通過它可以實現對 Bitmap 的各種扭曲。我們試一下用它把影象扭出水波紋的效果。

和 Material Design 裡扁平化的水波紋不同,這裡是通過對影象的處理,模擬真實的水波紋效果,最後實現的效果如下:

浪起來!使用 drawBitmapMesh 實現模擬水波紋效果

drawBitmapMesh 簡介

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

將一個圖片橫向、縱向均勻切割成 n 份,就會形成一個「網格」,我把所有網格線的交點稱為「頂點」。

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

浪起來!使用 drawBitmapMesh 實現模擬水波紋效果

接下來看看 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 就可以了。

建立 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;
}複製程式碼

計算偏移座標

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

浪起來!使用 drawBitmapMesh 實現模擬水波紋效果

我定義了一個 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();
}複製程式碼

方法裡面遍歷了所有的頂點,如果頂點是在水波範圍內,則需要對這個頂點進行偏移。

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

為了讓水波有突起的感覺,以水波中間(波峰)為分界線,裡面的頂點往裡偏移,外面的頂點往外偏移:

浪起來!使用 drawBitmapMesh 實現模擬水波紋效果

至於偏移的距離,我想要實現類似放大鏡的效果,離波峰越近的頂點,偏移的距離會越大。離波峰的距離和偏移距離的關係,可以看作一個餘弦曲線:

浪起來!使用 drawBitmapMesh 實現模擬水波紋效果

我們來看一下 getRipplePoint 方法,傳入的引數是原點的座標及需要轉換的頂點座標,在它裡面做了下面這些處理:

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

浪起來!使用 drawBitmapMesh 實現模擬水波紋效果

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 方法。

到這就完成啦。妥妥的。

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

原始碼地址

相關文章