在 Android 的畫布 Canvas 裏面有個 drawBitmapMesh 方法,經過它能夠實現對 Bitmap 的各類扭曲。咱們試一下用它把圖像扭出水波紋的效果。java
和 Material Design 裏扁平化的水波紋不一樣,這裏是經過對圖像的處理,模擬真實的水波紋效果,最後實現的效果以下:git
咱們先了解一下「網格」的概念。github
將一個圖片橫向、縱向均勻切割成 n 份,就會造成一個「網格」,我把全部網格線的交點稱爲「頂點」。算法
正常狀況下,頂點是均勻分佈的。當咱們改變了頂點的位置時,系統會拿偏移後的頂點座標,和原來的座標進行對比,經過一套算法,將圖片進行扭曲,像這樣:canvas
接下來看看 drawBitmapMesh 方法:數組
public void drawBitmapMesh(Bitmap bitmap, int meshWidth, int meshHeight, float[] verts, int vertOffset, int[] colors, int colorOffset, Paint paint)複製代碼
它的參數以下:緩存
須要說明一下的是,能夠用 colors 這個參數來實現陰影的效果,但在 API 18 如下開啓了硬件加速,colors 這個參數是不起做用的。咱們這裏只關注前面四個參數,後面四個傳 0、null、0、null 就能夠了。ide
建立自定義控件 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 及對應數組 */
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 方法。
到這就完成啦。妥妥的。
對了,不建議用這個控件包裹可滑動或者有動畫的控件,由於在繪製水波的時候,子控件的變化都是看不到的。