Inside the Android Canvas there is a drawBitmapMesh method that allows you to distort a Bitmap. Let’s try to use it to create ripples in the image.

Different from the flat water ripple in Material Design, the real water ripple effect is simulated through image processing here, and the final effect is as follows:

DrawBitmapMesh profile

Let’s start with the concept of a grid.

A “grid” is formed by evenly cutting an image into n pieces horizontally and vertically, and I call the intersection of all the grid lines “vertices”.

Normally, vertices are evenly distributed. When we change the position of the vertex, the system compares the offset vertex coordinates with the original coordinates, and uses a set of algorithms to distort the image like this:

Let’s look at the drawBitmapMesh method:

public void drawBitmapMesh(Bitmap bitmap,
                           int meshWidth,
                           int meshHeight,
                           float[] verts,
                           int vertOffset,
                           int[] colors,
                           int colorOffset,
                           Paint paint)Copy the code

Its parameters are as follows:

  • Bitmap – A bitmap that needs to be converted
  • MeshWidth – The number of horizontal cells, must be greater than 0
  • MeshHeight – The number of vertical cells, must be greater than 0
  • Verts – an array of grid vertex coordinates, recording the coordinates of each vertex of the distorted image. The array size is (meshWidth+1). (meshHeight+1) 2 + vertOffset
  • VertOffset – The number of vertices from which the bitmap is distorted, usually passing 0
  • Colors – Sets the color of the grid vertices, which is superimposed with the color of the corresponding pixel of the bitmap. The array size is (meshWidth+1) * (meshHeight+1) + colorOffset. Null can be passed
  • ColorOffset – The number of vertices from which to switch colors, usually passing 0
  • Paint – “brush”, can pass null

It should be noted that colors can be used to create shadows, but with hardware acceleration enabled below API 18, colors does not work. We only focus on the first four parameters and pass 0, NULL, 0, null to the next four parameters.

Create RippleLayout

Create a custom RippleLayout control. In order to make the control more flexible, I made it inherit FrameLayout. .

The following member variables are defined:

// The number of horizontal and vertical squares
private final int MESH_WIDTH = 20;
private final int MESH_HEIGHT = 20;
// The number of vertices in the image
private final int VERTS_COUNT = (MESH_WIDTH + 1) * (MESH_HEIGHT + 1);
// The original coordinate array
private final float[] staticVerts = new float[VERTS_COUNT * 2];
// The converted coordinate array
private final float[] targetVerts = new float[VERTS_COUNT * 2];
// The picture of the current control
private Bitmap bitmap;
// Half the width of the wave
private float rippleWidth = 100f;
// Water wave diffusion velocity
private float rippleSpeed = 15f;
// Water wave radius
private float rippleRadius;
// Whether the water wave animation is executing
private boolean isRippling;Copy the code

Look at the comments to see what it means, and we’ll use it next.

Then he defines a method that is often used here, calculating the distance of the diagonal in terms of width and height (Pythagorean theorem) :

/** * get the diagonal distance ** based on the width and height@paramWide width *@paramHigh height *@returnDistance * /
private float getLength(float width, float height) {
    return (float) Math.sqrt(width * width + height * height);
}Copy the code

Obtain Bitmap

To process a Bitmap, the first step is to get the Bitmap, and then initialize the two vertex coordinates according to the width and height of the Bitmap:

/** * Initializes the Bitmap and corresponding array */
private void initData(a) {
    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; }}}/** * Get the cache View of the View **@paramView corresponds to view *@returnThe cache View of the corresponding 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;
}Copy the code

Calculate offset coordinates

Here’s the point. The location of the water wave to be implemented here is in the gray area below:

I defined a warp method to redraw the Bitmap according to the coordinates (origin) pressed by the finger:

/** * image conversion **@paramOriginX originX coordinates *@paramOriginY the originY coordinate */
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 {
            / / recovery
            targetVerts[i] = staticVerts[i];
            targetVerts[i + 1] = staticVerts[i + 1];
        }
    }
    invalidate();
}Copy the code

All vertices are traversed in the method. If the vertex is in the range of the water wave, the vertex needs to be offset.

The idea of coordinate calculation after offset is like this:

To make the water wave feel raised, the middle of the wave (crest) is divided, with the inside vertex offset inward and the outside vertex offset outward:

As for the offset distance, I wanted to achieve a magnifying glass effect, the closer the vertices to the crest of the wave, the greater the offset distance. The relationship between the distance from the crest and the offset distance can be regarded as a cosine curve:

Let’s look at the getRipplePoint method, which takes the coordinates of the origin and the vertex coordinates to convert, and does the following:

  1. Obtain the horizontal Angle between the vertex and the origin by using the arctangent function:

float angle = (float) Math.atan(Math.abs((staticY - originY) / (staticX - originX)));Copy the code
  1. Calculate the vertex offset distance by cosine function:
float length = getLength(staticX - originX, staticY - originY);
float rate = (length - rippleRadius) / rippleWidth;
float offset = (float) Math.cos(rate) * 10f;Copy the code

Here 10F is the maximum offset distance.

  1. The calculated offset distance is the straight line distance, which needs to be converted into horizontal and vertical offset distance by cosine and sine functions according to the Angle between the vertex and the origin:
float offsetX = offset * (float) Math.cos(angle);
float offsetY = offset * (float) Math.sin(angle);Copy the code
  1. Based on the original coordinates of the vertex and the offset, the offset coordinates can be calculated, as to whether to add or subtract, depending on the vertex position.

The complete code for getRipplePoint is as follows:

/** * get the offset coordinates of the water wave **@paramOriginX originX coordinates *@paramOriginY the originY coordinate *@paramStaticX The original x-coordinate of the vertex to be offset *@paramStaticY the original y-coordinate of the vertex to be offset *@returnOffset coordinate */
private PointF getRipplePoint(float originX, float originY, float staticX, float staticY) {
    float length = getLength(staticX - originX, staticY - originY);
    // The Angle between offset and origin
    float angle = (float) Math.atan(Math.abs((staticY - originY) / (staticX - originX)));
    // Calculate the offset distance
    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);
    // Calculate the offset coordinates
    float targetX;
    float targetY;
    if (length < rippleRadius + rippleWidth && length > rippleRadius) {
        // The offset coordinates outside the wave crest
        if (staticX > originY) {
            targetX = staticX + offsetX;
        } else {
            targetX = staticX - offsetX;
        }
        if (staticY > originY) {
            targetY = staticY + offsetY;
        } else{ targetY = staticY - offsetY; }}else {
        // The offset coordinates within the wave crest
        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);
}Copy the code

I don’t know if it’s physically correct, but it feels like it.

Execute the water wave animation

You know the event distribution mechanism, as a ViewGroup, you do dispatchTouchEvent first. I performed the waterwave animation before the event was distributed, which also kept the event delivery intact:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            showRipple(ev.getX(), ev.getY());
            break;
    }
    return super.dispatchTouchEvent(ev);
}Copy the code

The job of showRipple is to loop warp and change the radius of the water wave so that it spreads outward:

/** * display water wave animation **@paramOriginX originX coordinates *@paramOriginY the originY coordinate */
public void showRipple(final float originX, final float originY) {
    if (isRippling) {
        return;
    }
    initData();
    if (bitmap == null) {
        return;
    }
    isRippling = true;
    // The number of cycles, calculated by the control diagonal distance, to ensure that the water ripples completely disappear
    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; }}}); }Copy the code

RxJava 2 is used here to realize the loop, the number of loops is calculated according to the control diagonal, to ensure that the water wave will completely disappear. The next wave animation will be executed only when you click after the wave disappears.

Attention! Let’s get to the point.

I haven’t even talked about drawBitmapMesh yet. The ViewGroup draws its child controls with dispatchDraw, and the last invalidate() called by the warp method also triggers the execution of dispatchDraw, so it can be manipulated here:

@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); }}Copy the code

If you’re customizing a View, you need to modify the onDraw method.

So we’re done here. No problem.

By the way, it is not recommended to wrap sliding or animated controls with this control because the changes to the child controls are invisible when drawing the water wave.

The source address