从源码角度带你实现支持scaleType的聊天气泡

之前在重构公司聊天库的时候发现聊天气泡用的全部都采用9patch格式的图片,采用这种方式文字还好,但图片的话会有一圈白边,效果不是很好。



而且万一之后需要改个颜色,改个位置还需要设计重新作图。

本着帮设计师小姐姐减少工作量的初心,先去github搜了下bubbleView。试了下star最多的两个项目,对图片的支持都不是很好。其中一个是气泡viewgroup,内嵌一个imageView的话,相当于只是一个可配置颜色、大小、边线的9patch。另一个有实现一个bubbleImageView,但是有限制。作者要求你一定要指定宽或者高的大小,并且指定另一个为wrap_content,然后会自动帮你把图片缩放到原始比例。所以他是不支持scaleType的。这样的话,如果要显示一张非常非常长的图片就gg了。而且我在使用的过程中遇到了一个bug,如下图:


下面我会解释为什么会产生这个bug。

因为以上种种原因,我决定自己写一个跟原生ImageView行为完全一致的bubbleImageView。

文章有点长,如果想直接使用的话请移步git,内附使用说明

走进科学

不太完美的实现

我们先来看一下上面提到的BubbleImageView的实现原理。

BubbleImageView主要负责一些属性的初始化,用于构造一个BubbleDrawable,最后在onDraw的时候调用BubbleDrawable的draw方法将气泡绘制到界面上。所以我们主要看下BubbleDrawable这个类。

BubbleDrawable重写了getIntrinsicWidth以及getIntrinsicHeight方法:

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

    @Override
    public int getIntrinsicHeight() {
        return (int) mRect.height();
    }

这两个方法返回的是drawable的宽和高,ImageView默认会根据这两个值来进行测量以及在绘制之前做一些处理,具体下面会提到。在这里他取的是BubbleImageView传进来的一个RectF,这个RectF的值就是ImageView的四个边界。

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

canvas.drawBitmap方法可以绘制一张矩形的图片到画布上,那要怎么绘制别的形状的图片到画布上呢?

很简单,paint.setShader方法允许给画笔设置着色器,并且android提供了BitmapShader。

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

后面两个参数分别指定在X、Y两个方向上的平铺方式,这里指定为复制边缘的颜色。
接下来调用

canvas.drawPath(mPath, mPaint);

就可以对这个路径进行绘制并用mBitmapShader着色了。路径的生成就不细说了,就是调用path的各个方法拼出一个气泡形状而已。

但这样绘制出来的图片可能并是不正确的,因为绘制时是以这张图片的原始尺寸为基准的,所以在绘制之前还需要对mBitmapShader进行一下变换。

private void setUpShaderMatrix() {
        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);
}

以上就是BubbleImageView的实现原理。下面我们来解释一下为什么会出现上面图片贴出的那个bug。

在BubbleImageView的onMeasure中有这么一段代码

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

我们再看下ImageView 中onMeasure方法的部分源码。

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);
}```
```java
private void updateDrawable(Drawable d) {
    ...
    mDrawable = d;
    if (d != null) {
        ...
        mDrawableWidth = d.getIntrinsicWidth();
        mDrawableHeight = d.getIntrinsicHeight();
        ...
    } else {
        ...
    }
}```
然后d.getIntrinsicWidth(),又是ImageBubbleView在onMeasure之后传给BubbleDrawable的。所以当你高度写成固定值,宽度写成wrap_content就会被onMeasure里的代码设置成一个正方形,反之亦然。在缩放mBitmapShader的时候因为Drawable的宽高比与图片原始宽高比不一致,之前说过TileMode.CLAMP模式会复制边缘颜色进行填充,所以就形成了上面图片中的效果。

##ImageView是怎么做的
下面我们看一下ImageView和BitmapDrawable是怎么处理的(为了看起来更清楚,下面的代码是对其两者处理方式的简化)

###BitmapDrawable
```java
public int getIntrinsicWidth() {
    if (mBitmap != null) return mBitmap.getWidth();
    else return -1;
}

public int getIntrinsicHeight() {
    if (mBitmap != null) return mBitmap.getHeight();
    else return -1;
}
 public void draw(Canvas canvas) {
   //mDstRect为绘制区域矩形
    final Rect bounds = getBounds();
    final int layoutDirection = getLayoutDirection();
    Gravity.apply(mBitmapState.mGravity, mBitmapWidth,mBitmapHeight,bounds, mDstRect, layoutDirection);

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

可以看到在BitmapDrawable中并没有做太多的处理。

ImageView

在ImageView中,每次onLayout、位移动画、setImageMatrix、更新drawable之后都会调用一个叫做configureBounds的方法,正是这个方法处理了BitmapDrawable应该如何显示。

private void configureBounds() {
    //drawable可绘制区域的宽
    final int vwidth = getWidth() - mPaddingLeft - mPaddingRight;
    //drawable可绘制区域的高
    final int vheight = getHeight() - mPaddingTop - mPaddingBottom;

    if (mDrawableWidth <= 0 || mDrawableHeight <= 0 || ScaleType.FIT_XY == mScaleType) {
        //在fitXY的时候,mDrawable会被设置成ImageView的大小
        mDrawable.setBounds(0, 0, vwidth, vheight);
        mDrawMatrix = null;
    } else {
        //其余情况,mDrawable都为自己本身的大小
        mDrawable.setBounds(0, 0, mDrawableWidth, mDrawableHeight);

        if (ScaleType.MATRIX == mScaleType) {
            //mDrawMatrix,会对传给Drawable的canvas进行变换
            if (mMatrix.isIdentity()) {
                mDrawMatrix = null;
            } else {
                mDrawMatrix = mMatrix;
            }
        } else if ((mDrawableWidth < 0 || vwidth == mDrawableWidth)
            && (mDrawableHeight < 0 || vheight == mDrawableHeight)) {
            //如果宽高相同或者drawable的宽高有一个为0则不进行变换
            mDrawMatrix = null;
        } else if (ScaleType.CENTER == mScaleType) {
            //center是把drawable的中心点和ImageView的中心点进行对齐
            mDrawMatrix = mMatrix;
            mDrawMatrix.setTranslate(Math.round((vwidth - mDrawableWidth) * 0.5f),
                                     Math.round((vheight - mDrawableHeight) * 0.5f));
        } else if (ScaleType.CENTER_CROP == mScaleType) {
            //centerCrop确保drawable缩放后确保更接近ImageView宽或高的两条边长与其相等的边然后沿着另一个方向居中
            mDrawMatrix = mMatrix;
            float scale;
            float dx = 0, dy = 0;
            if (mDrawableWidth * vheight > vwidth * mDrawableHeight) {
                scale = (float) vheight / (float) mDrawableHeight;
                dx = (vwidth - mDrawableWidth * scale) * 0.5f;
            } else {
                scale = (float) vwidth / (float) mDrawableWidth;
                dy = (vheight - mDrawableHeight * scale) * 0.5f;
            }
            mDrawMatrix.setScale(scale, scale);
            mDrawMatrix.postTranslate(Math.round(dx), Math.round(dy));
        } else if (ScaleType.CENTER_INSIDE == mScaleType) {
            //centerInside如果drawable宽高都小于imageview则居中,否则缩放到整个drawable都在imageView内后居中
            mDrawMatrix = mMatrix;
            float scale;
            float dx;
            float dy;
            if (mDrawableWidth <= vwidth && mDrawableHeight <= vheight) {
                scale = 1.0f;
            } else {
                scale = Math.min((float) vwidth / (float) mDrawableWidth,
                        (float) vheight / (float) mDrawableHeight);
            }
            dx = Math.round((vwidth - mDrawableWidth * scale) * 0.5f);
            dy = Math.round((vheight - mDrawableHeight * scale) * 0.5f);
            mDrawMatrix.setScale(scale, scale);
            mDrawMatrix.postTranslate(dx, dy);
        } else {
            // 处理Fit_Start、Fit_End、Fit_Center,native方法
            mTempSrc.set(0, 0, mDrawableWidth, mDrawableHeight);
            mTempDst.set(0, 0, vwidth, vheight);
            mDrawMatrix = mMatrix;
            mDrawMatrix.setRectToRect(mTempSrc, mTempDst, scaleTypeToScaleToFit(mScaleType));
        }
    }
}
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);
    }
}

更完美的实现

知道了官方的处理方式,我们开始写自己的BubbleDrawable和BubbleImageView。

首先模仿BitmapDrawable重写BubbleDrawable的getIntrinsicHeight与getIntrinsicWidth方法。

因为还是需要画一个气泡形状的path,所以我们还是采取给paint设置着色器的方式。因为我们已经知道了ImageView会根据scaleType设置drawable的bounds,所以我们可以在onBoundsChange中对bitmapShader进行缩放以确保FitXY正常(其他几种情况bounds都等于drawable原本的大小)。

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);
}

接下来只要在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);
}

接下来就是实现BubbleImageView了。

如果是在xml中指定的drawable,那么在ImageView的构造方法中会调用setImageDrawable方法设置drawable。所以只要重写setImageDrawable方法把原先的bitmapDrawable替换成自己构造的bubbleDrawable就可以了。

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

private void setUp() {
    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();
}

但是运行起来你会神奇的发现除了FitXY,其他的几种方式好像都不太对,比如箭头和圆角看起来很小,或者干脆箭头就不见了。没关系,既然我们已经知道了ImageView的原理,我们自己针对各种模式做一下处理就好了。

@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);
            
            //获取缩放以及偏移
            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为了使圆角和箭头大小正常,offset调整path边界
            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);
    }
}

好了到此为止一个与ImageView行为完全一致的BubbleImageView就写好了,下面放几张图片示例。

center_crop.jpg
center_inside.jpg
fit_xy.jpg
fit_end.jpg

最后再附上一遍git地址,欢迎star, issue和pr。

推荐阅读更多精彩内容