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

效果图

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

布局

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


布局

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

辅助线画了又画,头皮挠了又挠,α,θ,OMG......sin,cos,sh*t......终于算出了rDn的关系:

公式1

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


公式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.FillType和PorterDuff.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的一些功能,如自更新、动画、状态等。

  • Drawable自更新和动画Drawable
    Drawable的自更新和动画Drawable(如AnimationDrawableAnimatedVectorDrawable等)都是依赖于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)方法。下面展示一下效果:

AnimationDrawable
  • 状态
    一些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();
        }
    }
    

    效果:


    状态

好了,到这里控件算是完成了。
其他效果展示:

效果1

效果2

源代码:https://github.com/YiiGuxing/CompositionAvatar
我的GitHub:https://github.com/YiiGuxing
欢迎Star,谢谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容