When I reconstructed the company’s chat library before, I found that all the pictures in the chat bubbles were in 9patch format. The text was fine in this way, but the pictures had a white edge, which was not very good.





And in case you need to change a color, change a position also need to redesign the drawing.

In order to help designers reduce their workload, I went to Github and searched bubbleView. I have tried the two projects with the most star, but the picture support is not very good. One of them is a bubble ViewGroup, which, with an imageView embedded in it, is just a 9Patch with configurable colors, sizes, and edges. The other one implements a bubbleImageView, but with restrictions. The author asks you to specify either the width or height, and specify wrAP_content, which will automatically scale the image to its original size. So he doesn’t support scaleType. In this case, if you want to display a very, very long image, it will be gg. And I encountered a bug in the process of using, as shown in the picture below:





I’ll explain why this bug occurs below.

For all of these reasons, I decided to write my own bubbleImageView that behaves exactly like the native ImageView.

This article is a bit long. If you want to use git directly, please go to git

Into the science

A less than perfect implementation

Let’s take a look at the BubbleImageView implementation mentioned above.

BubbleImageView is responsible for initializing some properties, constructing a BubbleDrawable, and finally calling the BubbleDrawable draw method on onDraw to draw the bubble onto the interface. So let’s focus on the BubbleDrawable class.

BubbleDrawable overwrites the getIntrinsicWidth and getIntrinsicHeight methods:

@Override
    public int getIntrinsicWidth(a) {
        return (int) mRect.width();
    }

    @Override
    public int getIntrinsicHeight(a) {
        return (int) mRect.height();
    }Copy the code

These methods return the width and height of the drawable, which ImageView uses by default to measure and do some processing before drawing, as discussed below. In this case, he’s taking a RectF that BubbleImageView passed in, and the values of the RectF are the four edges of the ImageView.

private void setUp(int left, int right, int top, int bottom){
        if (right <= left || bottom <= top)
            return; . RectF rectF =newRectF(left, top, right, bottom); . }Copy the code

The canvas. DrawBitmap method draws a rectangular image onto the canvas. How do I draw another shape onto the canvas?

Quite simply, the paint.setshader method allows you to set shaders for brushes, and Android provides BitmapShader.

if (mBitmapShader == null) {
    mBitmapShader = new BitmapShader(bubbleBitmap,
                            Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
}
mPaint.setShader(mBitmapShader);Copy the code

The last two parameters specify the tile mode in X and Y directions respectively, in this case specifying the color of the copy edge. Next call

canvas.drawPath(mPath, mPaint);Copy the code

You can draw this path and color it with mBitmapShader. Path generation is not detailed, just call the various methods of path to form a bubble shape.

However, the picture drawn in this way may not be correct, because the drawing is based on the original size of the picture, so it needs to transform the mBitmapShader before drawing.

private void setUpShaderMatrix(a) {
        float scale;
        Matrix mShaderMatrix = new Matrix();
        mShaderMatrix.set(null);
        int mBitmapWidth = bubbleBitmap.getWidth();
        int mBitmapHeight = bubbleBitmap.getHeight();
        float scaleX = getIntrinsicWidth() / (float) mBitmapWidth;
        float scaleY = getIntrinsicHeight() / (float) mBitmapHeight;
        scale = Math.min(scaleX, scaleY);
        mShaderMatrix.postScale(scale, scale);
        mShaderMatrix.postTranslate(mRect.left, mRect.top);
        mBitmapShader.setLocalMatrix(mShaderMatrix);
}Copy the code

So that’s how BubbleImageView works. Let’s explain why the above image posted the bug.

There’s this code in the BubbleImageView onMeasure

if (width <= 0 && height > 0){
    setMeasuredDimension(height , height);
}
if (height <= 0 && width > 0){
    setMeasuredDimension(width , width);
}Copy the code

Let’s take a look at the ImageView onMeasure method part of the source.

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {...if (mDrawable == null) {
        // If no drawable, its intrinsic size is 0.
        mDrawableWidth = -1;
        mDrawableHeight = -1;
        w = h = 0;
    } else {
        w = mDrawableWidth;
        h = mDrawableHeight;
        if (w <= 0) w = 1;
        if (h <= 0) h = 1; . }...if(resizeWidth || resizeHeight) { ... }}else{... widthSize = resolveSizeAndState(w, widthMeasureSpec,0);
        heightSize = resolveSizeAndState(h, heightMeasureSpec, 0);
    }
    setMeasuredDimension(widthSize, heightSize);
}Copy the code
private void updateDrawable(Drawable d) {... mDrawable = d;if(d ! =null) {... mDrawableWidth = d.getIntrinsicWidth(); mDrawableHeight = d.getIntrinsicHeight(); . }else{... }}Copy the code

Then d.goetIntrinsicWidth (), which is the ImageBubbleView passed to the BubbleDrawable after the onMeasure. So when you write the height as a fixed value and the width as wrap_content it will be set to a square by the code in the onMeasure and vice versa. When scaling mBitmapShader, the aspect ratio of the Drawable is not consistent with the original aspect ratio of the picture. As mentioned before, TileMode.CLAMP mode will copy the edge color to fill, so the effect in the picture above is formed.

So what does ImageView do

Let’s take a look at how ImageView and BitmapDrawable are handled. (For clarity, the following code simplifies how both are handled.)

BitmapDrawable

public int getIntrinsicWidth(a) {
    if(mBitmap ! =null) return mBitmap.getWidth();
    else return -1;
}

public int getIntrinsicHeight(a) {
    if(mBitmap ! =null) return mBitmap.getHeight();
    else return -1;
}Copy the code
 public void draw(Canvas canvas) {
   //mDstRect is to draw the region rectangle
    final Rect bounds = getBounds();
    final int layoutDirection = getLayoutDirection();
    Gravity.apply(mBitmapState.mGravity, mBitmapWidth,mBitmapHeight,bounds, mDstRect, layoutDirection);

    canvas.drawBitmap(mBitmap, null, mDstRect, paint);
}Copy the code

You can see there’s not a lot of processing going on in BitmapDrawable.

ImageView

In ImageView, after every onLayout, displacement animation, setImageMatrix, and drawable update, a method called configureBounds is called, which handles how the BitmapDrawable should be displayed.

private void configureBounds(a) {
    Drawable Specifies the width of the drawable area
    final int vwidth = getWidth() - mPaddingLeft - mPaddingRight;
    //drawable Draws the height of the region
    final int vheight = getHeight() - mPaddingTop - mPaddingBottom;

    if (mDrawableWidth <= 0 || mDrawableHeight <= 0 || ScaleType.FIT_XY == mScaleType) {
        // On fitXY, mDrawable is set to the size of ImageView
        mDrawable.setBounds(0.0, vwidth, vheight);
        mDrawMatrix = null;
    } else {
        // In other cases, the mDrawable is its own size
        mDrawable.setBounds(0.0, mDrawableWidth, mDrawableHeight);

        if (ScaleType.MATRIX == mScaleType) {
            //mDrawMatrix, which transforms the canvas passed to Drawable
            if (mMatrix.isIdentity()) {
                mDrawMatrix = null;
            } else{ mDrawMatrix = mMatrix; }}else if ((mDrawableWidth < 0 || vwidth == mDrawableWidth)
            && (mDrawableHeight < 0 || vheight == mDrawableHeight)) {
            // If the width and height of drawable are the same or one of the width and height of drawable is 0, the conversion is not performed
            mDrawMatrix = null;
        } else if (ScaleType.CENTER == mScaleType) {
            // Center aligns the center of drawable with the center of ImageView
            mDrawMatrix = mMatrix;
            mDrawMatrix.setTranslate(Math.round((vwidth - mDrawableWidth) * 0.5 f),
                                     Math.round((vheight - mDrawableHeight) * 0.5 f));
        } else if (ScaleType.CENTER_CROP == mScaleType) {
            //centerCrop ensures that the drawable is scaled to ensure that the two edges closer to the width or height of the ImageView are of equal length to the edges and then centered in the other direction
            mDrawMatrix = mMatrix;
            float scale;
            float dx = 0, dy = 0;
            if (mDrawableWidth * vheight > vwidth * mDrawableHeight) {
                scale = (float) vheight / (float) mDrawableHeight;
                dx = (vwidth - mDrawableWidth * scale) * 0.5 f;
            } else {
                scale = (float) vwidth / (float) mDrawableWidth;
                dy = (vheight - mDrawableHeight * scale) * 0.5 f;
            }
            mDrawMatrix.setScale(scale, scale);
            mDrawMatrix.postTranslate(Math.round(dx), Math.round(dy));
        } else if (ScaleType.CENTER_INSIDE == mScaleType) {
            CenterInside If the drawable is smaller than the imageView, otherwise the entire drawable is centered in the ImageView
            mDrawMatrix = mMatrix;
            float scale;
            float dx;
            float dy;
            if (mDrawableWidth <= vwidth && mDrawableHeight <= vheight) {
                scale = 1.0 f;
            } else {
                scale = Math.min((float) vwidth / (float) mDrawableWidth,
                        (float) vheight / (float) mDrawableHeight);
            }
            dx = Math.round((vwidth - mDrawableWidth * scale) * 0.5 f);
            dy = Math.round((vheight - mDrawableHeight * scale) * 0.5 f);
            mDrawMatrix.setScale(scale, scale);
            mDrawMatrix.postTranslate(dx, dy);
        } else {
            // Handle Fit_Start, Fit_End, Fit_Center,native methods
            mTempSrc.set(0.0, mDrawableWidth, mDrawableHeight);
            mTempDst.set(0.0, vwidth, vheight); mDrawMatrix = mMatrix; mDrawMatrix.setRectToRect(mTempSrc, mTempDst, scaleTypeToScaleToFit(mScaleType)); }}}Copy the code
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    if (mDrawable == null) {
        return; 
    }
    if (mDrawableWidth == 0 || mDrawableHeight == 0) {
        return;
    }
    if (mDrawMatrix == null && mPaddingTop == 0 && mPaddingLeft == 0) {
        mDrawable.draw(canvas);
    } else {
        final int saveCount = canvas.getSaveCount();
        canvas.save();
        if(mDrawMatrix ! =null) { canvas.concat(mDrawMatrix); } mDrawable.draw(canvas); canvas.restoreToCount(saveCount); }}Copy the code

A more perfect realization

Knowing the official treatment, we started writing our own BubbleDrawable and BubbleImageView.

The BubbleDrawable getIntrinsicHeight and getIntrinsicWidth methods are overwritten by mimicking BitmapDrawable.

Since we still need to draw a bubble-shaped path, we’ll set the paint shader instead. Because we already know that ImageView sets the bounds of the drawable based on the scaleType, So we can scale the bitmapShader in onBoundsChange to make sure FitXY is normal (in several other cases bounds are equal to the size of drawable itself).

protected void onBoundsChange(Rect bounds) {
    dirtyDraw = true;
    updateShaderMatrix(bounds);
    mShaderMatrix.set(null);
    final int mBitmapWidth = bitmap.getWidth();
    final int mBitmapHeight = bitmap.getHeight();
    float scaleX = (bounds.width() * 1f) / mBitmapWidth;
    float scaleY = (bounds.height() * 1f) / mBitmapHeight;
    mShaderMatrix.setScale(scaleX, scaleY);
    bitmapShader.setLocalMatrix(mShaderMatrix);
}Copy the code

Next, just calculate the path drawing in draw

public void draw(Canvas canvas) {
    if (bitmap == null) {
        return;
    }

    if (dirtyDraw) {
        final Rect bounds = getBounds();
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
            final int layoutDirection = getLayoutDirection();
            Gravity.apply(Gravity.FILL, bitmap.getWidth(), bitmap.getHeight(),
                    bounds, mDstRect, layoutDirection);
        } else {
            Gravity.apply(Gravity.FILL, bitmap.getWidth(), bitmap.getHeight(),
                    bounds, mDstRect);
        }
    }
    configureRadiusRect();
    dirtyDraw = false;

    setUpPath();
    canvas.drawPath(path, bitmapPaint);
}Copy the code

The next step is to implement BubbleImageView.

If the drawable is specified in XML, the setImageDrawable method is called in the ImageView constructor to set the drawable. So just override the setImageDrawable method and replace your bitmapDrawable with your bubbleDrawable.

@Override
public void setImageDrawable(Drawable drawable) {
    if (preSetUp || drawable == null) return;
    bitmap = getBitmapFromDrawable(drawable);
    setUp();
    super.setImageDrawable(bubbleDrawable);
}

private void setUp(a) {
    if (bitmap == null) bitmap = getBitmapFromDrawable(getDrawable());
    if (bitmap == null) return;
    bubbleDrawable = new BubbleDrawable.Builder()
            .setBitmap(bitmap)
            .setOffset(offset)
            .setOrientation(orientation)
            .setRadius(radius)
            .setBorderColor(borderColor)
            .setBorderWidth(borderWidth)
            .setTriangleWidth(triangleWidth)
            .setTriangleHeight(triangleHeight)
            .setCenterArrow(centerArrow)
            .build();
}Copy the code

But when you run it, you’ll be surprised to see that none of the other ways seem right, except for FitXY. For example, the arrows and rounded corners look small, or the arrows simply disappear. It doesn’t matter, now that we know how ImageView works, we can do our own processing for the various modes.

@Override
protected void onDraw(Canvas canvas) {
    final Matrix mDrawMatrix = getImageMatrix();

    if (mDrawMatrix == null) {
        bubbleDrawable.draw(canvas);
    } else {
        final int saveCount = canvas.getSaveCount();
        canvas.save();

        if(mDrawMatrix ! =null) {
            canvas.concat(mDrawMatrix);

            // Get the zoom and offset
            mDrawMatrix.getValues(matrixValues);
            final float scaleX = matrixValues[Matrix.MSCALE_X];
            final float scaleY = matrixValues[Matrix.MSCALE_Y];
            final float translateX = matrixValues[Matrix.MTRANS_X];
            final float translateY = matrixValues[Matrix.MTRANS_Y];
            final ScaleType scaleType = getScaleType();

            //scale To keep rounded corners and arrows normal, offset adjusts the path boundary
            if (scaleType == ScaleType.CENTER) {
                bubbleDrawable.setOffsetLeft(-translateX);
                bubbleDrawable.setOffsetTop(-translateY);
                bubbleDrawable.setOffsetBottom(-translateY);
                bubbleDrawable.setOffsetRight(-translateX);
            } else if (scaleType == ScaleType.CENTER_CROP) {
                float scale = scaleX > scaleY ? 1 / scaleY : 1 / scaleX;
                bubbleDrawable.setOffsetLeft(-translateX * scale);
                bubbleDrawable.setOffsetTop(-translateY * scale);
                bubbleDrawable.setOffsetBottom(-translateY * scale);
                bubbleDrawable.setOffsetRight(-translateX * scale);
                bubbleDrawable.setScale(scale);
            } else {
                bubbleDrawable.setScale(scaleX > scaleY ? 1 / scaleY : 1/ scaleX); } } bubbleDrawable.draw(canvas); canvas.restoreToCount(saveCount); }}Copy the code

Ok, so that’s it. A BubbleImageView that behaves exactly like an ImageView has been written. Here are some examples of images.





center_crop.jpg





center_inside.jpg





fit_xy.jpg





fit_end.jpg

Finally, attach git address again, welcome star, issue and PR.