在 Android 的畫布 Canvas 裡面有個 drawBitmapMesh 方法,通過它可以實現對 Bitmap 的各種扭曲。我們試一下用它把影象扭出水波紋的效果。
和 Material Design 裡扁平化的水波紋不同,這裡是通過對影象的處理,模擬真實的水波紋效果,最後實現的效果如下:
drawBitmapMesh 簡介
我們先了解一下「網格」的概念。
將一個圖片橫向、縱向均勻切割成 n 份,就會形成一個「網格」,我把所有網格線的交點稱為「頂點」。
正常情況下,頂點是均勻分佈的。當我們改變了頂點的位置時,系統會拿偏移後的頂點座標,和原來的座標進行對比,通過一套演算法,將圖片進行扭曲,像這樣:
接下來看看 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;
}複製程式碼
計算偏移座標
接下來是重點了。這裡要實現的水波的位置在下圖的灰色區域:
我定義了一個 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 方法,傳入的引數是原點的座標及需要轉換的頂點座標,在它裡面做了下面這些處理:
- 通過反正切函式獲取到頂點和原點間的水平角度:
float angle = (float) Math.atan(Math.abs((staticY - originY) / (staticX - originX)));複製程式碼
- 通過餘弦函式計算頂點的偏移距離:
float length = getLength(staticX - originX, staticY - originY);
float rate = (length - rippleRadius) / rippleWidth;
float offset = (float) Math.cos(rate) * 10f;複製程式碼
這裡的 10f 是最大偏移距離。
- 計算出來的偏移距離是直線距離,還需要根據頂點和原點的角度,用餘弦、正弦函式將它轉換成水平、豎直方向的偏移距離:
float offsetX = offset * (float) Math.cos(angle);
float offsetY = offset * (float) Math.sin(angle);複製程式碼
- 根據頂點原來的座標和偏移量就可以得出偏移後的座標了,至於是加還是減,還要看頂點所在的位置。
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 方法。
到這就完成啦。妥妥的。
對了,不建議用這個控制元件包裹可滑動或者有動畫的控制元件,因為在繪製水波的時候,子控制元件的變化都是看不到的。