美文网首页
群组头像拼接

群组头像拼接

作者: 三流之路 | 来源:发表于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;
    }
    

    相关文章

      网友评论

          本文标题:群组头像拼接

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