自定义View之仿QQ讨论组头像

mavm0077 7年前
   <p style="text-align:center"><img src="https://simg.open-open.com/show/3d37aedbd0a9b7f196fb689b8372e3b2.png"></p>    <p style="text-align:center">效果图</p>    <p>在以前的一个项目中,需要实现类似QQ讨论组头像的控件,只是头像数量和布局有一小点不一样:一是最头像数是4个,二是头像数是2个时的布局是横着排的。其实当时GitHub上就有类似的开源控件,只是那个控件在每一次绘制View的时候都会新创建一些Bitmap对象,这肯定是不可取的,而且那个控件头像输入的是Bitmap对象,不满足需求。所以只能自己实现一个了。实现的时候也没有过多的考虑,传入头像Drawable对象,根据数量排列显示就算完成了,而且传入的图像还必需是圆形的,限制很大,根本不具备通用性。因此要实现和QQ讨论组头像一样的又具备一定通用性的控件,还得重新设计、实现。下面就让我们开始实现吧。</p>    <h2>布局</h2>    <p>首先需要解决的是头像的布局,在头像数量分别为1至5的情况下,定义头像的布局排列方式,并计算出图像的大小和位置。先把布局图画出来再说:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/e4869d2019c0a372d9e1508164f5b571.png"></p>    <p style="text-align:center">布局</p>    <p>其中黑色正方形就是View的显示区,蓝色圆形就是头像了。已知的条件是View大小,姑且设为 D 吧,还有头像的数量 n ,求蓝色圆的半径 r 及圆心位置。这不就是一道几何题吗?翻开初中的数学课本——勾三股四弦五……好像不够用啊……</p>    <p>辅助线画了又画,头皮挠了又挠,α,θ,OMG......sin,cos,sh*t......终于算出了 r 与 D 和 n 的关系:</p>    <p><img src="https://simg.open-open.com/show/ecf710138ab590a987af6ac8d7cc8dc9.png"></p>    <p>公式1</p>    <p>其实 n=3 的时候半径和 n=4 的时候是一样的,但是考虑到 n=3,5 时在Y轴上还有一个偏移量 dy ,而且 r 和 dy 在 n=3,5 时是有通式的,所以就合在一起了。求偏移量 dy 的公式:</p>    <p><img src="https://simg.open-open.com/show/4d724ec06ddae056bb836c2db570c854.png"></p>    <p>公式2</p>    <p>式中 R 就是布局图中红色大圆的半径。</p>    <p>有了公式,那么代码就好写了,计算每个头像的大小和位置的代码如下:</p>    <pre>  <code class="language-java">// 头像信息类,记录大小、位置等信息  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();  }</code></pre>    <pre>  <code class="language-java">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();  }</code></pre>    <h2>绘制</h2>    <p>计算好每个头像的大小和位置后,就可以把它们绘制出来了。但在此之前,还得先解决一个问题——如何使头像图像变圆?因为输入Drawable对象并没有任何限制。在上面的 layoutDrawables 方法中有这样两行代码:</p>    <pre>  <code class="language-java">drawable.mMaskPath.addCircle(drawable.mCenterX, drawable.mCenterY, r, Path.Direction.CW);  drawable.mMaskPath.setFillType(Path.FillType.INVERSE_WINDING);</code></pre>    <p>其中第一行是添加一个圆形路径,这个路径就是布局图中蓝色圆的路径,而第二行是设置路径的填充模式,默认的填充模式是填充路径内部,而 INVERSE_WINDING 模式是填充路径外部,再配合 Paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)) 就可以绘制出圆形的图像了。头像上的缺口同理。(ps:关于Path.FillType和PorterDuff.Mode网上介绍挺多的,这里就不详细介绍了)</p>    <p>下面来看一下 onDraw 方法:</p>    <pre>  <code class="language-java">@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);      }  }</code></pre>    <h2>Drawable支持</h2>    <p>既然输入的是 Drawable 对象,那就不能像 Bitmap 那样绘制出来就完事了的,除非你不打算支持Drawable的一些功能,如自更新、动画、状态等。</p>    <ul>     <li style="text-align:center"> <p>Drawable自更新和动画Drawable</p> <p>Drawable的自更新和动画Drawable(如 AnimationDrawable , AnimatedVectorDrawable 等)都是依赖于 Drawable.Callback 接口。其定义如下:</p> <pre>  <code class="language-java">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);  }</code></pre> <p>所以要支持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自己的背景和前景:</p> <pre>  <code class="language-java">protected boolean verifyDrawable(@NonNull Drawable who) {      // ...      return who == mBackground || (mForegroundInfo != null && mForegroundInfo.mDrawable == who);  }</code></pre> <p>所以只是设置了Callback的话,当Drawable内容改变需要重新绘制时View还是不会更新重绘的,动画需要计划下一帧或者取消一个计划时也不会成功。因此我们也得验证自己的Drawable:</p> <pre>  <code class="language-java">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);  }</code></pre> <p>此时,Drawable自更新的支持和动画Drawable的支持基本上是完成了。当然,View不可见和 onDetachedFromWindow() 时应该是要暂停或者停止动画的,这些在这里就不多说了,可以去看源码(在文章结尾处有链接),主要是调用 Drawable.setVisible(boolean, boolean) 方法。下面展示一下效果:</p> <img src="https://simg.open-open.com/show/0e5e94a592870961d3e7dc81b19219da.gif"> <p>AnimationDrawable</p> </li>     <li style="text-align:center"> <p>状态</p> <p>一些Drawable是有状态的,它能根据View的状态(按下,选中,激活等)改变其显示内容,如 StateListDrawable 。要支持View状态的话,其实只要扩展 View.drawableStateChanged() 和 View.jumpDrawablesToCurrentState() 方法,当View的状态改变的时候更新Drawable的状态就行了:</p> <pre>  <code class="language-java">// 状态改变时被调用  @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();      }  }</code></pre> <p>效果:</p> <img src="https://simg.open-open.com/show/ad5c42f1dc6ee3ba2399818d7a7712bd.gif"> <p>状态</p> </li>    </ul>    <p>好了,到这里控件算是完成了。</p>    <p>其他效果展示:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/1076615dddae9520cfee46f486f14dcd.gif"></p>    <p style="text-align:center">效果1</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/367800eb417ddf26e8a9d60c54693a9f.gif"></p>    <p style="text-align:center">效果2</p>    <p> </p>    <p> </p>    <p>项目主页:<a href="http://www.open-open.com/lib/view/home/1493260565185">http://www.open-open.com/lib/view/home/1493260565185</a></p>    <p> </p>