需求
聊天群组头像要拼成下图样式,最多显示 5 个头像,虽然我觉得两个人已经不是群组了,但是功能上可以删减人,依然保持群组。
截屏2021-01-08 下午2.07.18.png自定义 View 方式实现
思路
要将多个人头像拼成一个,最初的设想是自定义 View,在 onDraw 里自己将多个 Bitmap 绘制上去。
首先分析这个设计效果,假设整个图片的宽为 width,高为 height:
-
2 个人头像
未命名.png大圆半径为 radius,小圆半径是 0.5*radius,并且第一张图从 0°,第二张图在 180°。
-
5 个人头像,3、4 原理一样
未命名2.png比如 5 个人头像,有一张图被盖住了两边,所以认为是第一张被绘制的图,在 270° 的位置,看设计尺寸,小图变径是 0.4*radius。然后每张图的圆心角度都增加 360°/5。
drawBitmap 需要指定范围 RectF,只要知道小图圆心坐标就行了。而所有小图的圆心都在红色圆上,比如蓝色小圆,圆心在最外面大圆的角度是第一张图所在 270° 加 360°/5(图片数目)*2(自己顺序),也是相对于红色圆的角度。
通过 PathMeasure 的 getLength() 先计算出红色圆的周长,然后通过 getgetPosTan() 算出这个圆心的坐标。
// View 宽 width,高 height,大圆半径 radius,头像小圆半径 bitmapRadius,小图索引为 i,第一张图坐标是 startAngle
Path path = new Path();
// 红色圆,辅助计算头像小图圆心坐标
path.addCircle(width/2f, height/2f, radius - bitmapRadius, Path.Direction.CW);
// 测出红色圆总长度
PathMeasure measure = new PathMeasure();
measure.setPath(path, false);
float length = measure.getLength();
// 头像小圆圆心在红色圆上的角度
float angle = startAngle + i * 360f/bitmapSize;
if (angle > 360) {
angle -= 360;
}
// 计算头像小圆圆心坐标
float[] pos = new float[2];
measure.getPosTan(length * angle / 360f, pos, null);
// 根据坐标和半径圈出头像小图的范围
RectF rectF = new RectF(pos[0] - bitmapRadius, pos[1] - bitmapRadius, pos[0] + bitmapRadius, pos[1] + bitmapRadius);
// 根据这个范围绘制头像
canvas.drawBitmap(bitmaps.get(i), null, rectF, paint);
实现
自定义 View
public class MucAvatar extends ImageView {
private Paint paint;
public MucAvatar(Context context) {
super(context);
}
public MucAvatar(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public MucAvatar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
{
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(Color.parseColor("#E1E0E0"));
}
private List<Bitmap> bitmaps;
private float totalRadius;
// 要绘制的所有头像
public void setBitmap(List<Bitmap> bitmaps) {
if (bitmaps == null || bitmaps.isEmpty()) return;
if (bitmaps.size() == 1) {
// 一张图就直接设置
setImageBitmap(bitmaps.get(0));
} else {
this.bitmaps = bitmaps;
invalidate(); // 绘制
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (bitmaps == null || bitmaps.size() <= 1) return;
int width = getWidth();
int height = getHeight();
// 理论上 View 在 xml 中应该写宽高一样的
totalRadius = Math.min(width, height)/2f;
// 先画上一个圆底
canvas.drawCircle(width/2f, height/2f, totalRadius, paint);
switch (bitmaps.size()) {
// case 1:
// canvas.drawBitmap(bitmaps.get(0), null, new RectF(width/2f-totalRadius,height/2f-totalRadius,width/2f+totalRadius,height/2f+totalRadius), paint);
// break;
case 2:
drawBitmaps(canvas, 0.5f, 0);
break;
case 3:
drawBitmaps(canvas, 0.5f, 270);
break;
case 4:
drawBitmaps(canvas, 9f/20f, 45);
break;
default:
drawBitmaps(canvas, 0.4f, 270);
break;
}
}
/**
* @param radiusScale 是整体半径的几分之几
* @param startAngle 第一张图片,相对于0度的偏移角度
*/
private void drawBitmaps(Canvas canvas, float radiusScale, float startAngle) {
float bitmapRadius = totalRadius * radiusScale;
int bitmapSize = Math.min(bitmaps.size(), 5);
Path path = new Path();
path.addCircle(getWidth()/2f, getHeight()/2f, totalRadius - bitmapRadius, Path.Direction.CW);
PathMeasure measure = new PathMeasure();
measure.setPath(path, false);
float length = measure.getLength();
float[] pos = new float[2];
for (int i = 0; i < bitmapSize; i++) {
float angle = startAngle + i * 360f/bitmapSize;
if (angle > 360) {
angle -= 360;
}
measure.getPosTan(length * angle / 360f, pos, null);
RectF rectF = new RectF(pos[0] - bitmapRadius, pos[1] - bitmapRadius, pos[0] + bitmapRadius, pos[1] + bitmapRadius);
canvas.drawBitmap(bitmaps.get(i), null, rectF, paint);
}
}
}
然后
// 用 glide 拿到 Bitmap
public static void getAvatarBitmap(Fragment fragment, String url, SingleObserver<Bitmap> singleObserver) {
io.reactivex.Single.create(new SingleOnSubscribe<Bitmap>() {
@Override
public void subscribe(@io.reactivex.annotations.NonNull SingleEmitter<Bitmap> emitter) throws Exception {
Bitmap bitmap;
try {
bitmap = Glide.with(fragment)
.asBitmap()
.load(mUrl)
.apply(RequestOptions.circleCropTransform())
.priority(Priority.LOW)
.submit(BaseUtil.dp2px(20), BaseUtil.dp2px(20))
.get();
} catch (Exception e) {
bitmap = BitmapFactory.decodeResource(fragment.getResources(), R.drawable.defalut);
}
emitter.onSuccess(bitmap);
}
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(singleObserver);
}
MucAvatar mucAvatar; // xml 中用这个控件
List<Bitmap> bitmaps = new ArrayList<>();
for (int i = 0; i<bitmapSize; i++) {
ImageLoaderHelper.getAvatarBitmap(fragment, url, new SingleObserver<Bitmap>() {
@Override
public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) {
}
@Override
public void onSuccess(@io.reactivex.annotations.NonNull Bitmap bitmap) {
bitmaps.add(bitmap);
if (bimmaps.size()==bitmapSize) {
mucAvatar.setBitmap(bitmaps);
}
}
@Override
public void onError(@io.reactivex.annotations.NonNull Throwable e) {
}
});
}
若在 RecyclerView 中使用,会因复用产生错乱问题,要加 tag,Bitmap 列表取回来后进行对比,这已经不是这个头像拼接本身问题了。
自定义 Glide ModuleLoader 方式
实现自定义 ModuleLoader
参考文档,这种方式使用起来更优雅,更简洁。
除了 glide 依赖还要添加 annotationProcessor "com.github.bumptech.glide:compiler:$rootProject.glideVersion"
-
定义好要加载图片的 modle 类,比如用户类叫 MucMember,里面有个字段 url 表示头像地址
public class MucMember { private String url; ... } public class MucAvatarModel { private List<MucMember> mucMembers; public MucAvatarModel(List<MucMember> mucMembers) { this.mucMembers = mucMembers; } public List<MucMember> getMucMembers() { return mucMembers; } }
-
自定义 ModuleLoader,从上面的 MucAvatarModel 得到一个最终图片的流
public class MucAvatarLoader implements ModelLoader<MucAvatarModel, InputStream> { @Nullable @Override public LoadData<InputStream> buildLoadData(@NonNull MucAvatarModel model, int width, int height, @NonNull Options options) { String key = xxx; // 每个 model 独一无二的东西,作为最终图像缓存的键 return new LoadData<>(new ObjectKey(key), new MucAvatarDataFetcher(model.getMucMembers(), width, height)); } @Override public boolean handles(@NonNull MucAvatarModel model) { // url 不为空时处理 return TextUtils.isEmpty(model.getUrl); } }
-
MucAvatarDataFetcher 用于处理加载图片
public class MucAvatarDataFetcher implements DataFetcher<InputStream> { private final List<MucMember> mucMembers; private final int width, height; private float totalRadius; private Paint paint; private InputStream inputStream; private Context applicationContext; MucAvatarDataFetcher(List<MucMember> mucMembers, int width, int height) { this.mucMembers = mucMembers; this.width = width; this.height = height; paint = new Paint(Paint.ANTI_ALIAS_FLAG); paint.setColor(Color.parseColor("#E1E0E0")); totalRadius = Math.min(width, height)/2f; applicationContext = ...; } @Override public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) { io.reactivex.Single.create(new SingleOnSubscribe<List<Bitmap>>() { @Override public void subscribe(@io.reactivex.annotations.NonNull SingleEmitter<List<Bitmap>> emitter) throws Exception { List<Bitmap> bitmaps = new ArrayList<>(); for (int i = 0; i<mucMembers.size() && i<5; i++) { String key = ...; Bitmap bitmap; try { bitmap = Glide.with(applicationContext) .asBitmap() .load(mucMembers.get(i).getUrl()) .apply(RequestOptions.circleCropTransform()) .priority(Priority.LOW) .submit(dp2px(20), dp2px(20)) .get(); } catch (Exception e) { bitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.default); } bitmaps.add(bitmap); } emitter.onSuccess(bitmaps); } }).subscribeOn(Schedulers.io()) // .observeOn(AndroidSchedulers.mainThread()) .subscribe(new SingleObserver<List<Bitmap>>() { @Override public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) { } @Override public void onSuccess(@io.reactivex.annotations.NonNull List<Bitmap> bitmaps) { // 上面得到 Bitmap 后,下面类似自定义 View 的方式那样绘制到一个 Bitmap inputStream = bitmap2InputStream(createBitmap(bitmaps)); callback.onDataReady(inputStream); } @Override public void onError(@io.reactivex.annotations.NonNull Throwable e) { } }); } @Override public void cleanup() { // 要在这里关闭流 if (inputStream != null) { try { inputStream.close(); } catch (IOException e) { e.printStackTrace(); } } } @Override public void cancel() { } @NonNull @Override public Class<InputStream> getDataClass() { return InputStream.class; } @NonNull @Override public DataSource getDataSource() { return DataSource.REMOTE; // 图片是线上远程的,会默认缓存原始数据 } private Bitmap createBitmap(List<Bitmap> bitmaps) { // 创建空白背景 Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(output); canvas.drawCircle(width/2f, height/2f, totalRadius, paint); switch (bitmaps.size()) { case 1: return bitmaps.get(0); // canvas.drawBitmap(bitmaps.get(0), null, new RectF(width/2f-totalRadius,height/2f-totalRadius,width/2f+totalRadius,height/2f+totalRadius), paint); // break; case 2: drawBitmaps(bitmaps, canvas, 0.5f, 0); break; case 3: drawBitmaps(bitmaps, canvas, 0.5f, 270); break; case 4: drawBitmaps(bitmaps, canvas, 9f/20f, 45); break; default: drawBitmaps(bitmaps, canvas, 0.4f, 270); break; } return output; } /** * @param radiusScale 是整体半径的几分之几 * @param startAngle 第一张图片,相对于0度的偏移角度 */ private void drawBitmaps(List<Bitmap> bitmaps, Canvas canvas, float radiusScale, float startAngle) { float bitmapRadius = totalRadius * radiusScale; int bitmapSize = Math.min(bitmaps.size(), 5); Path path = new Path(); path.addCircle(width/2f, height/2f, totalRadius - bitmapRadius, Path.Direction.CW); PathMeasure measure = new PathMeasure(); measure.setPath(path, false); float length = measure.getLength(); float[] pos = new float[2]; for (int i = 0; i < bitmapSize; i++) { float angle = startAngle + i * 360f/bitmapSize; if (angle > 360) { angle -= 360; } measure.getPosTan(length * angle / 360f, pos, null); RectF rectF = new RectF(pos[0] - bitmapRadius, pos[1] - bitmapRadius, pos[0] + bitmapRadius, pos[1] + bitmapRadius); canvas.drawBitmap(bitmaps.get(i), null, rectF, paint); } } private InputStream bitmap2InputStream(Bitmap bm) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); bm.compress(Bitmap.CompressFormat.PNG, 100, baos); InputStream is = new ByteArrayInputStream(baos.toByteArray()); return is; } }
-
定义 AppGlideModule 注册 ModelLoader
@GlideModule // 一定要这个注解 public class BaseGlideModule extends AppGlideModule { @Override public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { registry.prepend(MucAvatarModel.class, InputStream.class, new MucAvatarLoaderFactory()); } }
-
创建 ModelLoaderFactory,泛型类型要和前面的匹配
public class MucAvatarLoaderFactory implements ModelLoaderFactory<MucAvatarModel, InputStream> { @NonNull @Override public ModelLoader<MucAvatarModel, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) { return new MucAvatarLoader(); } @Override public void teardown() { } }
使用自定义的 ModuleLoader 加载
List<MucMember> mucMembers
GlideApp.with(fragment.getContext())
.load(new MucAvatarModel(mucMembers))
.priority(Priority.LOW)
.placeholder(R.drawable.default)
.into(imageView);
群组图片闪烁问题
在网上搜到的,说 Target 不直接用 ImageView,用普通的 Target,在 onResourceReady 中手动 setImageDrawable,测试的确有用。
public class RecyclerViewItemTarget extends CustomTarget<Drawable> {
private ImageView iv;
private int position;
public RecyclerViewItemTarget(ImageView iv, int position) {
this.iv = iv;
this.position = position;
this.iv.setTag(position); // 复用后,可能被其它position改变这个tag
}
@Override
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
if ((int)iv.getTag() == position) {
iv.setImageDrawable(resource);
}
}
@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
iv = null;
}
}
后来发现有崩溃,原因是这个 Target 中宽高没设置,想起过去看 Glide3 的 SimpleTarget 了,Glide4 原理也类似,看源码必须把尺寸传进去,不如直接继承 ViewTarget 了,把 View 传进去,框架自己会监听 View 树的变化获取尺寸。
public class RecyclerViewItemTarget extends CustomViewTarget<ImageView, Drawable> {
private ImageView iv;
private int position;
public RecyclerViewItemTarget(ImageView iv, int position) {
super(iv); // 传给框架
this.iv = iv;
this.position = position;
this.iv.setTag(position); // 复用后,可能被其它position改变这个tag
}
@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
if ((int)iv.getTag() == position) {
iv.setImageDrawable(errorDrawable);
}
}
@Override
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
if ((int)iv.getTag() == position) {
iv.setImageDrawable(resource);
}
}
@Override
protected void onResourceCleared(@Nullable Drawable placeholder) {
// 不要设置,不然 RecyclerView item 点击去其它页面时会有个闪烁
}
}
显示效果一个压一个
突然变了设计,多张图片有重叠时,每一张图压着别人,也被别人压着,不能像原来那样第一张图被第二张和最后一张都压着。想想其实有很多中实现方法吧。
现在的思路是再搞一张和原来的大圆一模一样的原图,然后将第一张和最后一张以同样的位置在第二个画布上绘制,将两者做个效果,让第一张图被压的那一角跑到最后一张上面去,然后再将这张画布的图片覆盖到原来的上面。
使用 SRC_ATOP 模式,最后一张图先绘作为 DST(黄色),然后绘制第一张图作为 SRC(蓝色),这样第一张图的一角就盖在了最后一张图上面。
16101093058097.png修改 MucAvatarDataFetcher
private void drawBitmaps(List<Bitmap> bitmaps, Canvas canvas, float radiusScale, float startAngle) {
float bitmapRadius = totalRadius * radiusScale;
int bitmapSize = Math.min(bitmaps.size(), 5);
Path path = new Path();
path.addCircle(width / 2f, height / 2f, totalRadius - bitmapRadius, Path.Direction.CW);
PathMeasure measure = new PathMeasure();
measure.setPath(path, false);
float length = measure.getLength();
float[] pos = new float[2];
RectF rectF0 = null; // 记下第一张图片的位置
for (int i = 0; i < bitmapSize; i++) {
float angle = startAngle + i * 360f / bitmapSize;
if (angle > 360) {
angle -= 360;
}
measure.getPosTan(length * angle / 360f, pos, null);
RectF rectF = new RectF(pos[0] - bitmapRadius, pos[1] - bitmapRadius, pos[0] + bitmapRadius, pos[1] + bitmapRadius);
if (i == 0) {
rectF0 = rectF;
}
if (i == bitmapSize-1) {
// 最后一张图片,生成另一个大的图片
canvas.drawBitmap(getBitmapLast(bitmaps.get(i), bitmaps.get(0), rectF, rectF0), 0,0, paint);
} else {
// 其它图片还是按原来方式绘制
canvas.drawBitmap(bitmaps.get(i), null, rectF, paint);
}
}
}
private Bitmap getBitmapLast(Bitmap bitmapLast, Bitmap bitmap0, RectF rectFLast, RectF rectF0) {
// 新画布
Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
// 先绘制最后一张图
canvas.drawBitmap(bitmapLast, null, rectFLast, paint);
// 模式为 SRC_ATOP
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP));
// 绘制第一张图
canvas.drawBitmap(bitmap0, null, rectF0 ,paint);
return output;
}
网友评论