CircleImageView 解析与定制

CircleImageView 是一个非常轻量的实现圆形头像的类。GitHub 上 Start 数达到了 10k+,质量非常高。使用方法非常简单,具体参见 GitHub 文档。

本文通过源码进行分析,学习其实现原理,做简单的定制。

注:CircleImageView 中,fillColor 相关设置由于已经被废弃,下文中将不展示所有 fillColor 相关内容。

源码执行顺序

在本例子里,是在 xml 里设置了一张 Drawable 图片。这种情况下我们按照执行的方法顺序来看一下代码。加入 Log 将执行顺序打印出来,插入 Log 的方法参考博客:给每一个函数加一行LOG

public void setImageDrawable(Drawable drawable) {
private void initializeBitmap() {
private Bitmap getBitmapFromDrawable(Drawable drawable) {
private void setup() {
public void setAdjustViewBounds(boolean adjustViewBounds) {
// 父类已经执行完毕
public CircleImageView(Context context, AttributeSet attrs, int defStyle) {
private void init() {
private void setup() {
// xml 创建该 View,所以入口为这个构造函数,走到这里代表构造函数已经执行完毕
public CircleImageView(Context context, AttributeSet attrs) {
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
private void setup() {
private RectF calculateBounds() {
private void applyColorFilter() {
private void updateShaderMatrix() {
protected void onDraw(Canvas canvas) {

源码解析

构造函数中先调用 supper,所以我们先从其父类构造函数看起。

首先执行了 initImageView()

private void initImageView() {
    mMatrix = new Matrix();
    // 默认缩放方式设置为 FIT_CENTER
    mScaleType = ScaleType.FIT_CENTER;
    // 省略
}

可以看到 ImageView 默认设置了缩放方式为 FIT_CENTER,而实际上 CircleImageView 是仅支持 CENTER_CROP 方式的。后续 CircleImageView 会强制设定缩放方式。

接着由于 xml 里设置了 src ,则要执行 setImageDrawable(Drawable drawable)

 final Drawable d = a.getDrawable(R.styleable.ImageView_src);
 if (d != null) {
     setImageDrawable(d);
 }

CircleImageView 重写了此方法,根据类对象的执行顺序,执行的是 CircleImageView 中的方法:

@Override
public void setImageDrawable(Drawable drawable) {
    super.setImageDrawable(drawable);
    initializeBitmap();
}

可以看到 setImageXxxxx 这一系列的设置 View 图像的方法中,都调用了 initializeBitmap() 方法:

private void initializeBitmap() {
    if (mDisableCircularTransformation) {
        mBitmap = null;
    } else {
        // 获取 Bitmap
        mBitmap = getBitmapFromDrawable(getDrawable());
    }
    setup();
}

获取 Bitmap

private Bitmap getBitmapFromDrawable(Drawable drawable) {
    // 没有图片直接返回空
    if (drawable == null) {
        return null;
    }
    // BitmapDrawable 直接返回相应的 Bigmap
    if (drawable instanceof BitmapDrawable) {
        return ((BitmapDrawable) drawable).getBitmap();
    }

    try {
        Bitmap bitmap;

        if (drawable instanceof ColorDrawable) {
            // 创建 2dp 大小的 bitmap
            bitmap = Bitmap.createBitmap(COLORDRAWABLE_DIMENSION,
             COLORDRAWABLE_DIMENSION, BITMAP_CONFIG);
        } else {
            // 其他类型的 Drawable,创建对应大小的 bitmap
            // 由于此处可能会出现 getIntrinsicWidth 为 -1,需要捕获异常
            bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(),
             drawable.getIntrinsicHeight(), BITMAP_CONFIG);
        }
        // 创建指定的画布
        Canvas canvas = new Canvas(bitmap);
        // 指定该图片需要绘制的区域,这个区域就是 draw 方法调用时绘制的区域
        drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
        // 将图片绘制到画布上
        drawable.draw(canvas);
        return bitmap;
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
}

然后进行设置属性:

private void setup() {
    if (!mReady) {
        // 延迟设置属性
        mSetupPending = true;
        return;
    }
    // 省略
}

实际上此时 View 还未初始化,所以此处暂不设置 CircleImageView 的大小参数。

接下来回到父类构造函数继续查看,将调用 CircleImageView 复写的 setAdjustViewBounds 。为何要复写该方法呢,这是因为如果设定 android:adjustViewBounds="true" 则会将这个 ImageView 的 scaleType 设为 fitCenter。而前面提到 CircleImageView 是不支持除 CENTER_CROP 之外的缩放属性,所以,CircleImageView 中重写了该方法抛出异常来防止被错误设置:

@Override
public void setAdjustViewBounds(boolean adjustViewBounds) {
    // 不支持 adjustViewBounds 属性,因为此属性会设置 scaleType 为 fitCenter
    if (adjustViewBounds) {
        throw new IllegalArgumentException("adjustViewBounds not supported.");
    }
}

执行完 ImageView 的构造方法后,回到 CircleImageView 构造方法:

public CircleImageView(Context context) {
    super(context);

    init();
}

public CircleImageView(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}

public CircleImageView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);

    TypedArray a = context.obtainStyledAttributes(attrs,
     R.styleable.CircleImageView, defStyle, 0);

    // 环形边缘的宽度
    mBorderWidth =
     a.getDimensionPixelSize(R.styleable.CircleImageView_civ_border_width,
      DEFAULT_BORDER_WIDTH);
    // 环形边缘的颜色
    mBorderColor = a.getColor(R.styleable.CircleImageView_civ_border_color,
     DEFAULT_BORDER_COLOR);
    // 环形边缘是否覆盖图片
    mBorderOverlay = a.getBoolean(R.styleable.CircleImageView_civ_border_overlay,
     DEFAULT_BORDER_OVERLAY);

    a.recycle();

    init();
}

可以看到,在获取了设置的属性即环形边缘的宽度、颜色以及是否覆盖图片后,执行 init() 方法来真正地设置 CircleImageView 的属性。

private static final ScaleType SCALE_TYPE = ScaleType.CENTER_CROP;
private void init() {
    // 设置 scaleType 为 CENTER_CROP
    super.setScaleType(SCALE_TYPE);
    // View 已经准备好可以设置属性了
    mReady = true;
    // 此时根据标志位开始设置属性
    if (mSetupPending) {
        setup();
        mSetupPending = false;
    }
}

@Override
public void setScaleType(ScaleType scaleType) {
    if (scaleType != SCALE_TYPE) {
        throw new IllegalArgumentException(String.format("ScaleType %s not supported.", scaleType));
    }
}

如前所述, CircleImageView 将缩放方式设定为 CENTER_CROP,并禁止设置其他缩放方式。

此时执行 setup() 方法,View 大小为 0,无需设置属性。

private void setup() {
    if (!mReady) {
        mSetupPending = true;
        return;
    }
    // View 大小为 0
    if (getWidth() == 0 && getHeight() == 0) {
        return;
    }
    // 省略
}

在系统Measure/Layout时,会回调 onSizeChanged() 方法(onLayout 方法之前),此时 View 的大小就已经确定了,下一步就是对 View 进行绘制,因此要调用 setup() 来设置一下。

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    setup();
}

核心方法的参数计算与设置

这时调用 setup(),View 就有大小了,开始设置相应的属性,也就是把一张图片设置为圆形头像的方法的各个参数的计算与设置。具体见代码注释。

private void setup() {
    if (!mReady) {
        mSetupPending = true;
        return;
    }
    // View 大小为 0
    if (getWidth() == 0 && getHeight() == 0) {
        return;
    }
    // 无可设置 Bitmap
    if (mBitmap == null) {
        invalidate();
        return;
    }
    // BitmapShader 的作用在于:指定绘制来源为图片,将一个变换矩阵设置给 Bitmap 画笔
    mBitmapShader = new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
    // Bitmap 画笔设置抗锯齿
    mBitmapPaint.setAntiAlias(true);
    // 设置 BitmapShader,绘制时就是指定的图片内容,后面还会设置它的矩阵属性对图片作变换
    mBitmapPaint.setShader(mBitmapShader);

    // 边缘画笔设置为环形
    mBorderPaint.setStyle(Paint.Style.STROKE);
    // 边缘画笔设置抗锯齿
    mBorderPaint.setAntiAlias(true);
    // 边缘画笔设置颜色
    mBorderPaint.setColor(mBorderColor);
    // 边缘画笔设置环形宽度
    mBorderPaint.setStrokeWidth(mBorderWidth);

    // 获取图片的宽度与高度
    mBitmapHeight = mBitmap.getHeight();
    mBitmapWidth = mBitmap.getWidth();

    // 计算环形绘制边界区域
    mBorderRect.set(calculateBounds());
    // 计算环形的半径,环形半径为环形中心到环形的中间的长度
    // 所以方形区域 - 环形宽度 / 2 就是环形的半径了
    mBorderRadius = Math.min((mBorderRect.height() - mBorderWidth) / 2.0f,
     (mBorderRect.width() - mBorderWidth) / 2.0f);

    // 圆形图片的区域默认情况下就是环形计算出来的区域
    mDrawableRect.set(mBorderRect);
    if (!mBorderOverlay && mBorderWidth > 0) {
        // 此时环形不覆盖在图片上,图片区域比环形区域小
        mDrawableRect.inset(mBorderWidth - 1.0f, mBorderWidth - 1.0f);
    }
    // 圆形区域的半径就是圆形区域方形边缘长度的一半
    mDrawableRadius = Math.min(mDrawableRect.height() / 2.0f,
    mDrawableRect.width() / 2.0f);

    // 设置 Bitmap 画笔带的蒙层(滤镜)颜色
    applyColorFilter();
    // 图片显示时需要进行缩放、平移,通过矩阵来变换
    updateShaderMatrix();
    invalidate();
}

我们分别来看看 calculateBounds() applyColorFilter() updateShaderMatrix() 方法:

calculateBounds() 就是获取 View 当中可以绘制的最大的方形区域,并且这个区域定位于 View 的中心,使圆形头像是居中显示的。

private RectF calculateBounds() {
    // 获取 View 可以绘制的宽度与高度,可以看到支持 padding 属性的设置
    int availableWidth  = getWidth() - getPaddingLeft() - getPaddingRight();
    int availableHeight = getHeight() - getPaddingTop() - getPaddingBottom();

    // 由于是圆形头像,所以从可绘制的区域中找到一个最大的方形区域
    int sideLength = Math.min(availableWidth, availableHeight);

    // 将这个方形区域定位在 View 可绘制区域的中间区域
    float left = getPaddingLeft() + (availableWidth - sideLength) / 2f;
    float top = getPaddingTop() + (availableHeight - sideLength) / 2f;

    return new RectF(left, top, left + sideLength, top + sideLength);
}

applyColorFilter() 比较简单无需说明。

private void applyColorFilter() {
    if (mBitmapPaint != null) {
        mBitmapPaint.setColorFilter(mColorFilter);
    }
}

主要变换操作就是通过 updateShaderMatrix() 更新矩阵,通过这个矩阵对原始图片进行缩放并且裁切中心区域:

private void updateShaderMatrix() {
    float scale;
    float dx = 0;
    float dy = 0;

    mShaderMatrix.set(null);

    if (mBitmapWidth * mDrawableRect.height() >
            mDrawableRect.width() * mBitmapHeight) {
        // 如果图片宽高比大于控件的宽高比,缩放比例由高度决定
        scale = mDrawableRect.height() / (float) mBitmapHeight;
        // 缩放之后需要在 x 轴上进行平移,以得到图片正中心的图案
        dx = (mDrawableRect.width() - mBitmapWidth * scale) * 0.5f;
    } else {
        // 如果图片宽高比小于控件的宽高比,缩放比例由宽度决定
        scale = mDrawableRect.width() / (float) mBitmapWidth;
        // 缩放之后需要在 y 轴上进行平移,以得到图片正中心的图案
        dy = (mDrawableRect.height() - mBitmapHeight * scale) * 0.5f;
    }

    mShaderMatrix.setScale(scale, scale);
    mShaderMatrix.postTranslate((int) (dx + 0.5f) + mDrawableRect.left,
        (int) (dy + 0.5f) + mDrawableRect.top);

    mBitmapShader.setLocalMatrix(mShaderMatrix);
}

setup() 中,将执行 invalidate() 回调 onDraw() 将控件绘制出来。

@Override
protected void onDraw(Canvas canvas) {
    if (mDisableCircularTransformation) {
        super.onDraw(canvas);
        return;
    }

    if (mBitmap == null) {
        return;
    }
    // 绘制圆形图片
    canvas.drawCircle(mDrawableRect.centerX(), mDrawableRect.centerY(),
     mDrawableRadius, mBitmapPaint);
    if (mBorderWidth > 0) {
        // 绘制环形边缘
        canvas.drawCircle(mBorderRect.centerX(), mBorderRect.centerY(),
         mBorderRadius, mBorderPaint);
    }
}

以上就是 CircleImageView 控件的整个流程了。可以看到确实是很轻量的实现,而其实现方式性能也好。众所周知,我们不应用在 onDraw 里执行耗时操作,而该实现把需要计算的参数一开始就计算好了,onDraw 中使用了最简单的内容,无须进行计算,直接应用即可。

关于该实现方式的性能分析可以参考:Android-解析自定义view之圆形头像的各类方案

自定义修改

现在有一个需求,这个圆形头像需要随着滑动渐渐透明消失。我们知道设置一个图片的透明度的方法为:mImageView.setImageAlpha(int alpha)。那直接对创建的 CircleImageView 调用这个方法来设置行不行呢?毕竟它也是继承 ImageView 的嘛。

答案当然是不可以,原因就是它的 onDraw 已经与父类不一样了,ImageView 中,透明度生效是直接修改 mDrawable.setAlpha(),然后在 onDrawmDrawable.draw(canvas)。而 CircleImageView 中是使用了自定义的 Paint 进行绘制的。事实上这些原有的操作如果要支持就要修改自定义的 View,使得实现效果相同。

我们参考 CircleImageView 中复写了原有接口:设置滤镜颜色的 setColorFilter 方法:

public void setColorFilter(ColorFilter cf) {
    if (cf == mColorFilter) {
        return;
    }
    mColorFilter = cf;
    applyColorFilter();
    invalidate();
}

通过 applyColorFilter 方法(见前文),给画笔 mBitmapPaint 设置了 ColorFilter 颜色,再 invalidate() 触发绘制即可生效。所以参照这种方式来修改画笔属性,达到增加设置透明度的方法的目的。

增加一个 alpha 变量,然后方法写成如下即可:

private int mAlpha = 255;
@Override
public void setImageAlpha(int alpha) {
    if (alpha == mAlpha) {
        return;
    }

    mAlpha = alpha;
    applyAlpha();
    invalidate();
}

private void applyAlpha() {
    if (mBitmapPaint != null) {
        mBitmapPaint.setColorFilter(mColorFilter);
    }
}

最后,既然提供了修改的方法,也应该提供查询的方法:

@Override
public int getImageAlpha() {
    return mAlpha;
}

这样子,我们就给圆形头像加上了透明度的设置方法。

参考链接:

  1. CircleImageView
  2. 给每一个函数加一行LOG
  3. Android-解析自定义view之圆形头像的各类方案
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值