之前在重構(gòu)公司聊天庫(kù)的時(shí)候發(fā)現(xiàn)聊天氣泡用的全部都采用9patch格式的圖片,采用這種方式文字還好,但圖片的話會(huì)有一圈白邊,效果不是很好。
而且萬(wàn)一之后需要改個(gè)顏色,改個(gè)位置還需要設(shè)計(jì)重新作圖。
本著幫設(shè)計(jì)師小姐姐減少工作量的初心,先去github搜了下bubbleView。試了下star最多的兩個(gè)項(xiàng)目,對(duì)圖片的支持都不是很好。其中一個(gè)是氣泡viewgroup,內(nèi)嵌一個(gè)imageView的話,相當(dāng)于只是一個(gè)可配置顏色、大小、邊線的9patch。另一個(gè)有實(shí)現(xiàn)一個(gè)bubbleImageView,但是有限制。作者要求你一定要指定寬或者高的大小,并且指定另一個(gè)為wrap_content,然后會(huì)自動(dòng)幫你把圖片縮放到原始比例。所以他是不支持scaleType的。這樣的話,如果要顯示一張非常非常長(zhǎng)的圖片就gg了。而且我在使用的過(guò)程中遇到了一個(gè)bug,如下圖:
下面我會(huì)解釋為什么會(huì)產(chǎn)生這個(gè)bug。
因?yàn)橐陨戏N種原因,我決定自己寫(xiě)一個(gè)跟原生ImageView行為完全一致的bubbleImageView。
文章有點(diǎn)長(zhǎng),如果想直接使用的話請(qǐng)移步git,內(nèi)附使用說(shuō)明
走進(jìn)科學(xué)
不太完美的實(shí)現(xiàn)
我們先來(lái)看一下上面提到的BubbleImageView的實(shí)現(xiàn)原理。
BubbleImageView主要負(fù)責(zé)一些屬性的初始化,用于構(gòu)造一個(gè)BubbleDrawable,最后在onDraw的時(shí)候調(diào)用BubbleDrawable的draw方法將氣泡繪制到界面上。所以我們主要看下BubbleDrawable這個(gè)類(lèi)。
BubbleDrawable重寫(xiě)了getIntrinsicWidth以及getIntrinsicHeight方法:
@Override
public int getIntrinsicWidth() {
return (int) mRect.width();
}
@Override
public int getIntrinsicHeight() {
return (int) mRect.height();
}
這兩個(gè)方法返回的是drawable的寬和高,ImageView默認(rèn)會(huì)根據(jù)這兩個(gè)值來(lái)進(jìn)行測(cè)量以及在繪制之前做一些處理,具體下面會(huì)提到。在這里他取的是BubbleImageView傳進(jìn)來(lái)的一個(gè)RectF,這個(gè)RectF的值就是ImageView的四個(gè)邊界。
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方法可以繪制一張矩形的圖片到畫(huà)布上,那要怎么繪制別的形狀的圖片到畫(huà)布上呢?
很簡(jiǎn)單,paint.setShader方法允許給畫(huà)筆設(shè)置著色器,并且android提供了BitmapShader。
if (mBitmapShader == null) {
mBitmapShader = new BitmapShader(bubbleBitmap,
Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
}
mPaint.setShader(mBitmapShader);
后面兩個(gè)參數(shù)分別指定在X、Y兩個(gè)方向上的平鋪方式,這里指定為復(fù)制邊緣的顏色。
接下來(lái)調(diào)用
canvas.drawPath(mPath, mPaint);
就可以對(duì)這個(gè)路徑進(jìn)行繪制并用mBitmapShader著色了。路徑的生成就不細(xì)說(shuō)了,就是調(diào)用path的各個(gè)方法拼出一個(gè)氣泡形狀而已。
但這樣繪制出來(lái)的圖片可能并是不正確的,因?yàn)槔L制時(shí)是以這張圖片的原始尺寸為基準(zhǔn)的,所以在繪制之前還需要對(duì)mBitmapShader進(jìn)行一下變換。
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的實(shí)現(xiàn)原理。下面我們來(lái)解釋一下為什么會(huì)出現(xiàn)上面圖片貼出的那個(gè)bug。
在BubbleImageView的onMeasure中有這么一段代碼
if (width <= 0 && height > 0){
setMeasuredDimension(height , height);
}
if (height <= 0 && width > 0){
setMeasuredDimension(width , width);
}
我們?cè)倏聪翴mageView 中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的。所以當(dāng)你高度寫(xiě)成固定值,寬度寫(xiě)成wrap_content就會(huì)被onMeasure里的代碼設(shè)置成一個(gè)正方形,反之亦然。在縮放mBitmapShader的時(shí)候因?yàn)镈rawable的寬高比與圖片原始寬高比不一致,之前說(shuō)過(guò)TileMode.CLAMP模式會(huì)復(fù)制邊緣顏色進(jìn)行填充,所以就形成了上面圖片中的效果。
##ImageView是怎么做的
下面我們看一下ImageView和BitmapDrawable是怎么處理的(為了看起來(lái)更清楚,下面的代碼是對(duì)其兩者處理方式的簡(jiǎn)化)
###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為繪制區(qū)域矩形
final Rect bounds = getBounds();
final int layoutDirection = getLayoutDirection();
Gravity.apply(mBitmapState.mGravity, mBitmapWidth,mBitmapHeight,bounds, mDstRect, layoutDirection);
canvas.drawBitmap(mBitmap, null, mDstRect, paint);
}
可以看到在BitmapDrawable中并沒(méi)有做太多的處理。
ImageView
在ImageView中,每次onLayout、位移動(dòng)畫(huà)、setImageMatrix、更新drawable之后都會(huì)調(diào)用一個(gè)叫做configureBounds的方法,正是這個(gè)方法處理了BitmapDrawable應(yīng)該如何顯示。
private void configureBounds() {
//drawable可繪制區(qū)域的寬
final int vwidth = getWidth() - mPaddingLeft - mPaddingRight;
//drawable可繪制區(qū)域的高
final int vheight = getHeight() - mPaddingTop - mPaddingBottom;
if (mDrawableWidth <= 0 || mDrawableHeight <= 0 || ScaleType.FIT_XY == mScaleType) {
//在fitXY的時(shí)候,mDrawable會(huì)被設(shè)置成ImageView的大小
mDrawable.setBounds(0, 0, vwidth, vheight);
mDrawMatrix = null;
} else {
//其余情況,mDrawable都為自己本身的大小
mDrawable.setBounds(0, 0, mDrawableWidth, mDrawableHeight);
if (ScaleType.MATRIX == mScaleType) {
//mDrawMatrix,會(huì)對(duì)傳給Drawable的canvas進(jìn)行變換
if (mMatrix.isIdentity()) {
mDrawMatrix = null;
} else {
mDrawMatrix = mMatrix;
}
} else if ((mDrawableWidth < 0 || vwidth == mDrawableWidth)
&& (mDrawableHeight < 0 || vheight == mDrawableHeight)) {
//如果寬高相同或者drawable的寬高有一個(gè)為0則不進(jìn)行變換
mDrawMatrix = null;
} else if (ScaleType.CENTER == mScaleType) {
//center是把drawable的中心點(diǎn)和ImageView的中心點(diǎn)進(jìn)行對(duì)齊
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寬或高的兩條邊長(zhǎng)與其相等的邊然后沿著另一個(gè)方向居中
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則居中,否則縮放到整個(gè)drawable都在imageView內(nèi)后居中
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);
}
}
更完美的實(shí)現(xiàn)
知道了官方的處理方式,我們開(kāi)始寫(xiě)自己的BubbleDrawable和BubbleImageView。
首先模仿BitmapDrawable重寫(xiě)B(tài)ubbleDrawable的getIntrinsicHeight與getIntrinsicWidth方法。
因?yàn)檫€是需要畫(huà)一個(gè)氣泡形狀的path,所以我們還是采取給paint設(shè)置著色器的方式。因?yàn)槲覀円呀?jīng)知道了ImageView會(huì)根據(jù)scaleType設(shè)置drawable的bounds,所以我們可以在onBoundsChange中對(duì)bitmapShader進(jìn)行縮放以確保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);
}
接下來(lái)只要在draw的時(shí)候計(jì)算好路徑繪制即可
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);
}
接下來(lái)就是實(shí)現(xiàn)BubbleImageView了。
如果是在xml中指定的drawable,那么在ImageView的構(gòu)造方法中會(huì)調(diào)用setImageDrawable方法設(shè)置drawable。所以只要重寫(xiě)setImageDrawable方法把原先的bitmapDrawable替換成自己構(gòu)造的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();
}
但是運(yùn)行起來(lái)你會(huì)神奇的發(fā)現(xiàn)除了FitXY,其他的幾種方式好像都不太對(duì),比如箭頭和圓角看起來(lái)很小,或者干脆箭頭就不見(jiàn)了。沒(méi)關(guān)系,既然我們已經(jīng)知道了ImageView的原理,我們自己針對(duì)各種模式做一下處理就好了。
@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調(diào)整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);
}
}
好了到此為止一個(gè)與ImageView行為完全一致的BubbleImageView就寫(xiě)好了,下面放幾張圖片示例。
最后再附上一遍git地址,歡迎star, issue和pr。