美文网首页待集成android成神之路Android开发
从源码角度带你实现支持scaleType的聊天气泡

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

作者: Dajavu | 来源:发表于2017-05-16 18:41 被阅读505次

    之前在重构公司聊天库的时候发现聊天气泡用的全部都采用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。

    相关文章

      网友评论

      本文标题:从源码角度带你实现支持scaleType的聊天气泡

      本文链接:https://www.haomeiwen.com/subject/kbaxwttx.html