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()
,然后在 onDraw
中 mDrawable.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;
}
这样子,我们就给圆形头像加上了透明度的设置方法。
参考链接: