從源碼角度帶你實(shí)現(xiàn)支持scaleType的聊天氣泡

之前在重構(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ě)好了,下面放幾張圖片示例。

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

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評(píng)論 6 533
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,582評(píng)論 3 418
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 176,540評(píng)論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 63,028評(píng)論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,801評(píng)論 6 410
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 55,223評(píng)論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評(píng)論 3 442
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 42,442評(píng)論 0 289
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,976評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,800評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,996評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評(píng)論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,233評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 34,662評(píng)論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 35,926評(píng)論 1 286
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,702評(píng)論 3 392
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,991評(píng)論 2 374

推薦閱讀更多精彩內(nèi)容