美文网首页
群组头像拼接

群组头像拼接

作者: 三流之路 | 来源:发表于2021-01-08 20:52 被阅读0次

需求

聊天群组头像要拼成下图样式,最多显示 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"

  1. 定义好要加载图片的 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;
        }
    }
    
  2. 自定义 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);
        }
    }
    
  3. 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;
        }
    }
    
  4. 定义 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());
        }
    }
    
  5. 创建 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;
}

相关文章

  • 群组头像拼接

    需求 聊天群组头像要拼成下图样式,最多显示 5 个头像,虽然我觉得两个人已经不是群组了,但是功能上可以删减人,依然...

  • 仿微信生成群组头像

    背景 项目中涉及到群组聊天的功能,由于前期项目比较紧张,群组头像并没有按照群成员头像去自动生成,即类似微信群组头像...

  • 微信头像拼接

    这两天刚好项目上有一个头像拼接的需求,想必做即时通讯的很多都有这样的需求吧,所以就研究了一下微信的规则实现了一下。...

  • 一行代码实现群聊头像(用环信仿微信群聊头像)

    做这个环信群聊头像的时候,我在考虑怎么自定义,怎么去拼接这个群聊头像,怎么获取群成员的头像,怎么在群成员退出群聊的...

  • Mac 管理员【最近头像】删除方法

    用户与群组中,最近使用头像不用了,看着头像越来越多,作为一个强迫症很想删除怎么办? 1. 打开Finder 2. ...

  • 性能优化总结

    对于TableView的优化太多了,总结我知道的自己涉及的吧。1,之前群头像用几个头像的拼接,太多的UIImage...

  • 实现类似微信群组头像显示的控件

    最新新的需求对即时通信里面的群组及成员头像显示有了新的需求更改,显然需求跟微信的效果差不多,自然我也是对微信群组头...

  • 漂泊者的社交

    “我要退群了” “那我也退了” 我盯着群组头像中孤零零的自己的头像,这是前几天才换的,说不上熟悉,却有种那就是自己...

  • ios仿 微信九宫格群组

    正在做开发一个聊天工具,群组头像像仿照微信的头像那样显示成员头像合成图片,百度了一下,发现一个大神写的Stitch...

  • iOS 拼接群聊头像(模仿微信群聊头像)

    之前做过一个集成环信的项目,里面涉及到群聊部分,老大要求是做成微信群聊那种头像,自己写了个demo 模仿微信群聊头...

网友评论

      本文标题:群组头像拼接

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