浪起来!使用 drawBitmapMesh 实现仿真水波纹效果

在 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 方法,传入的参数是原点的坐标及需要转换的顶点坐标,在它里面做了下面这些处理:

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

到这就完成啦。妥妥的。

对了,不建议用这个控件包裹可滑动或者有动画的控件,因为在绘制水波的时候,子控件的变化都是看不到的。

源码地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,831评论 25 707
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,373评论 0 17
  • 1. 图像扭曲 Canvas中提供了一个drawBitmapMesh方法,通过该方法可以实现位图的扭曲效果,下面来...
    小芸论阅读 1,573评论 0 6
  • 锋芒 平时生活中,常见这样的现象,值得说一说,避免把和气伤。你有锋芒, 我也有锋芒, 恰似针尖对麦芒, 各人都示强...
    阿超Lilian阅读 147评论 0 0
  • 下一世,再也没有你 冬日的大雪覆盖了京都的整片街道. 她循着食物的香气在雪地里挪动着几近僵硬的身体. 看着石凳上的...
    古风啦啦啦阅读 231评论 0 2