之前在重构公司聊天库的时候发现聊天气泡用的全部都采用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就写好了,下面放几张图片示例。
最后再附上一遍git地址,欢迎star, issue和pr。