欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

Android自定义View模仿QQ讨论组头像效果

程序员文章站 2023-11-24 18:10:34
首先来看看我们模仿的效果图,相信对于使用过qq的人来说都不陌生,效果图如下: 在以前的一个项目中,需要实现类似qq讨论组头像的控件,只是头像数量和布局有一小点不一样:...

首先来看看我们模仿的效果图,相信对于使用过qq的人来说都不陌生,效果图如下:

Android自定义View模仿QQ讨论组头像效果

在以前的一个项目中,需要实现类似qq讨论组头像的控件,只是头像数量和布局有一小点不一样:一是最头像数是4个,二是头像数是2个时的布局是横着排的。其实当时github上就有类似的开源控件,只是那个控件在每一次绘制view的时候都会新创建一些bitmap对象,这肯定是不可取的,而且那个控件头像输入的是bitmap对象,不满足需求。所以只能自己实现一个了。实现的时候也没有过多的考虑,传入头像drawable对象,根据数量排列显示就算完成了,而且传入的图像还必需是圆形的,限制很大,根本不具备通用性。因此要实现和qq讨论组头像一样的又具备一定通用性的控件,还得重新设计、实现。

下面就让我们开始实现吧。

布局

首先需要解决的是头像的布局,在头像数量分别为1至5的情况下,定义头像的布局排列方式,并计算出图像的大小和位置。先把布局图画出来再说:

Android自定义View模仿QQ讨论组头像效果

布局

其中黑色正方形就是view的显示区,蓝色圆形就是头像了。已知的条件是view大小,姑且设为 d 吧,还有头像的数量 n ,求蓝色圆的半径 r 及圆心位置。这不就是一道几何题吗?翻开初中的数学课本——勾三股四弦五……好像不够用啊……

辅助线画了又画,头皮挠了又挠,α,θ,omg......sin,cos,sh*t......终于算出了 r 与 d 和 n 的关系:

Android自定义View模仿QQ讨论组头像效果

公式1

其实 n=3 的时候半径和 n=4 的时候是一样的,但是考虑到 n=3,5 时在y轴上还有一个偏移量 dy ,而且 r 和 dy 在 n=3,5 时是有通式的,所以就合在一起了。求偏移量 dy 的公式:

Android自定义View模仿QQ讨论组头像效果

公式2

式中 r 就是布局图中红色大圆的半径。

有了公式,那么代码就好写了,计算每个头像的大小和位置的代码如下:

// 头像信息类,记录大小、位置等信息
private static class drawableinfo {
 int mid = view.no_id;
 drawable mdrawable;
 // 中心点位置
 float mcenterx;
 float mcentery;
 // 头像上缺口弧所在圆上的圆心位置,其实就是下一个相邻头像的中心点
 float mgapcenterx;
 float mgapcentery;
 boolean mhasgap;
 // 头像边界
 final rectf mbounds = new rectf();
 // 圆形蒙板路径,把头像弄成圆形
 final path mmaskpath = new path();
}
private void layoutdrawables() {
 msteinercircleradius = 0;
 moffsety = 0;

 int width = getwidth() - getpaddingleft() - getpaddingright();
 int height = getheight() - getpaddingtop() - getpaddingbottom();

 mcontentsize = math.min(width, height);
 final list<drawableinfo> drawables = mdrawables;
 final int n = drawables.size();
 float center = mcontentsize * .5f;
 if (mcontentsize > 0 && n > 0) {
 // 图像圆的半径。
 final float r;
 if (n == 1) {
  r = mcontentsize * .5f;
 } else if (n == 2) {
  r = (float) (mcontentsize / (2 + 2 * math.sin(math.pi / 4)));
 } else if (n == 4) {
  r = mcontentsize / 4.f;
 } else {
  r = (float) (mcontentsize / (2 * (2 * math.sin(((n - 2) * math.pi) / (2 * n)) + 1)));
  final double sinn = math.sin(math.pi / n);
  // 以所有图像圆为内切圆的圆的半径
  final float r = (float) (r * ((sinn + 1) / sinn));
  moffsety = (float) ((mcontentsize - r - r * (1 + 1 / math.tan(math.pi / n))) / 2f);
 }

 // 初始化第一个头像的中心位置
 final float startx, starty;
 if (n % 2 == 0) {
  startx = starty = r;
 } else {
  startx = center;
  starty = r;
 }

 // 变换矩阵
 final matrix matrix = mlayoutmatrix;
 // 坐标点临时数组
 final float[] pointstemp = this.mpointstemp;

 matrix.reset();

 for (int i = 0; i < drawables.size(); i++) {
  drawableinfo drawable = drawables.get(i);
  drawable.reset();

  drawable.mhasgap = i > 0;
  // 缺口弧的中心
  if (drawable.mhasgap) {
  drawable.mgapcenterx = pointstemp[0];
  drawable.mgapcentery = pointstemp[1];
  }

  pointstemp[0] = startx;
  pointstemp[1] = starty;
  if (i > 0) {
  // 以上一个圆的圆心旋转计算得出当前圆的圆位置
  matrix.postrotate(360.f / n, center, center + moffsety);
  matrix.mappoints(pointstemp);
  }

  // 取出中心点位置
  drawable.mcenterx = pointstemp[0];
  drawable.mcentery = pointstemp[1];

  // 设置边界
  drawable.mbounds.inset(-r, -r);
  drawable.mbounds.offset(drawable.mcenterx, drawable.mcentery);

  // 设置“蒙板”路径
  drawable.mmaskpath.addcircle(drawable.mcenterx, drawable.mcentery, r, path.direction.cw);
  drawable.mmaskpath.setfilltype(path.filltype.inverse_winding);
 }

 // 设置第一个头像的缺口,头像数量少于3个的时候没有
 if (n > 2) {
  drawableinfo first = drawables.get(0);
  drawableinfo last = drawables.get(n - 1);
  first.mhasgap = true;
  first.mgapcenterx = last.mcenterx;
  first.mgapcentery = last.mcentery;
 }

 msteinercircleradius = r;
 }

 invalidate();
}

绘制

计算好每个头像的大小和位置后,就可以把它们绘制出来了。但在此之前,还得先解决一个问题——如何使头像图像变圆?因为输入drawable对象并没有任何限制。

在上面的 layoutdrawables 方法中有这样两行代码:

drawable.mmaskpath.addcircle(drawable.mcenterx, drawable.mcentery, r, path.direction.cw);
drawable.mmaskpath.setfilltype(path.filltype.inverse_winding);

其中第一行是添加一个圆形路径,这个路径就是布局图中蓝色圆的路径,而第二行是设置路径的填充模式,默认的填充模式是填充路径内部,而 inverse_winding 模式是填充路径外部,再配合 paint.setxfermode(new porterduffxfermode(porterduff.mode.clear)) 就可以绘制出圆形的图像了。头像上的缺口同理。(ps:关于path.filltypeporterduff.mode网上介绍挺多的,这里就不详细介绍了)

下面来看一下 ondraw 方法:

@override
protected void ondraw(canvas canvas) {
 super.ondraw(canvas);
 ...
 canvas.translate(0, moffsety);

 final paint paint = mpaint;
 final float gapradius = msteinercircleradius * (mgap + 1f);
 for (int i = 0; i < drawables.size(); i++) {
  drawableinfo drawable = drawables.get(i);
  rectf bounds = drawable.mbounds;
  final int savedlayer = canvas.savelayer(0, 0, mcontentsize, mcontentsize, null, canvas.all_save_flag);

  // 设置drawable的边界
  drawable.mdrawable.setbounds((int) bounds.left, (int) bounds.top,
    math.round(bounds.right), math.round(bounds.bottom));
  // 绘制drawable
  drawable.mdrawable.draw(canvas);

  // 绘制“蒙板”路径,将drawable绘制的图像“剪”成圆形
  canvas.drawpath(drawable.mmaskpath, paint);
  // “剪”出弧形的缺口
  if (drawable.mhasgap && mgap > 0f) {
   canvas.drawcircle(drawable.mgapcenterx, drawable.mgapcentery, gapradius, paint);
  }

  canvas.restoretocount(savedlayer);
 }
}

drawable支持

既然输入的是 drawable 对象,那就不能像 bitmap 那样绘制出来就完事了的,除非你不打算支持drawable的一些功能,如自更新、动画、状态等。

1、drawable自更新和动画drawable

drawable的自更新和动画drawable(如 animationdrawable , animatedvectordrawable 等)都是依赖于 drawable.callback 接口。其定义如下:

public interface callback {
 /**
  * 当drawable需要重新绘制时调用。此时的view应该使其自身失效(至少drawable展示部分失效)
  * @param who 要求重新绘制的drawable
  */
 void invalidatedrawable(@nonnull drawable who);

 /**
  * drawable可以通过调用该方法来安排动画的下一帧。
  * @param who 要预定的drawable
  * @param what 要执行的动作
  * @param when 执行的时间(以毫秒为单位),基于android.os.systemclock.uptimemillis()
  */
 void scheduledrawable(@nonnull drawable who, @nonnull runnable what, long when);

 /**
  * drawable可以通过调用该方法来取消先前通过scheduledrawable(drawable, runnable, long)调度的动作。
  * @param who 要取消预定的drawable
  * @param what 要取消执行的动作
  */
 void unscheduledrawable(@nonnull drawable who, @nonnull runnable what);
}

所以要支持drawable自更新和动画drawable,得通过 drawable.setcallback(drawable.callback) 方法设置 drawable.callback 接口的实现对象才行。好在 android.view.view 已经实现了这个接口,在设置drawable的时候调用一下 drawable.setcallback(myview.this) 即可。但需要注意的是, android.view.view 实现 drawable.callback 接口的时候都调用了 view.verifydrawable(drawable) 以验证需要显示更新的drawable是不是自己的drawable,且其实现只是验证了view自己的背景和前景:

protected boolean verifydrawable(@nonnull drawable who) {
 // ...
 return who == mbackground || (mforegroundinfo != null && mforegroundinfo.mdrawable == who);
}

所以只是设置了callback的话,当drawable内容改变需要重新绘制时view还是不会更新重绘的,动画需要计划下一帧或者取消一个计划时也不会成功。因此我们也得验证自己的drawable:

private boolean hassamedrawable(drawable drawable) {
 for (drawableinfo d : mdrawables) {
  if (d.mdrawable == drawable) {
   return true;
  }
 }
 return false;
}

@override
protected boolean verifydrawable(@nonnull drawable drawable) {
 return hassamedrawable(drawable) || super.verifydrawable(drawable);
}

此时,drawable自更新的支持和动画drawable的支持基本上是完成了。当然,view不可见和 ondetachedfromwindow() 时应该是要暂停或者停止动画的,这些在这里就不多说了,可以去看源码(在文章结尾处有链接),主要是调用 drawable.setvisible(boolean, boolean) 方法。

下面展示一下效果:

Android自定义View模仿QQ讨论组头像效果
animationdrawable

2、状态

一些drawable是有状态的,它能根据view的状态(按下,选中,激活等)改变其显示内容,如 statelistdrawable 。要支持view状态的话,其实只要扩展 view.drawablestatechanged() view.jumpdrawablestocurrentstate() 方法,当view的状态改变的时候更新drawable的状态就行了:

// 状态改变时被调用
@override
protected void drawablestatechanged() {
 super.drawablestatechanged();
 boolean invalidate = false;
 for (drawableinfo drawable : mdrawables) {
  drawable d = drawable.mdrawable;
  // 判断drawable是否支持状态并更新状态
  if (d.isstateful() && d.setstate(getdrawablestate())) {
   invalidate = true;
  }
 }
 if (invalidate) {
  invalidate();
 }
}

// 这个方法主要针对状态改变时有过渡动画的drawable
@override
public void jumpdrawablestocurrentstate() {
 super.jumpdrawablestocurrentstate();
 for (drawableinfo drawable : mdrawables) {
  drawable.mdrawable.jumptocurrentstate();
 }
}

效果:

Android自定义View模仿QQ讨论组头像效果
状态

好了,到这里控件算是完成了。

其他效果展示:

Android自定义View模仿QQ讨论组头像效果

效果1

Android自定义View模仿QQ讨论组头像效果

效果2

项目主页:https://github.com/yiiguxing/compositionavatar

本地下载:http://xiazai.jb51.net/201704/yuanma/compositionavatar-master(jb51.net).rar

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家学习或者工作能带来一定的帮助,如果有疑问大家可以留言交流,谢谢大家对的支持。