[Android] 自定義View之仿QQ討論組頭像

效果圖

在以前的一個(gè)項(xiàng)目中惧蛹,需要實(shí)現(xiàn)類似QQ討論組頭像的控件,只是頭像數(shù)量和布局有一小點(diǎn)不一樣:一是最頭像數(shù)是4個(gè)步势,二是頭像數(shù)是2個(gè)時(shí)的布局是橫著排的驹碍。其實(shí)當(dāng)時(shí)GitHub上就有類似的開(kāi)源控件,只是那個(gè)控件在每一次繪制View的時(shí)候都會(huì)新創(chuàng)建一些Bitmap對(duì)象脆烟,這肯定是不可取的山林,而且那個(gè)控件頭像輸入的是Bitmap對(duì)象,不滿足需求邢羔。所以只能自己實(shí)現(xiàn)一個(gè)了驼抹。實(shí)現(xiàn)的時(shí)候也沒(méi)有過(guò)多的考慮,傳入頭像Drawable對(duì)象拜鹤,根據(jù)數(shù)量排列顯示就算完成了框冀,而且傳入的圖像還必需是圓形的,限制很大敏簿,根本不具備通用性明也。因此要實(shí)現(xiàn)和QQ討論組頭像一樣的又具備一定通用性的控件宣虾,還得重新設(shè)計(jì)、實(shí)現(xiàn)温数。下面就讓我們開(kāi)始實(shí)現(xiàn)吧绣硝。

布局

首先需要解決的是頭像的布局,在頭像數(shù)量分別為1至5的情況下撑刺,定義頭像的布局排列方式鹉胖,并計(jì)算出圖像的大小和位置。先把布局圖畫(huà)出來(lái)再說(shuō):


布局

其中黑色正方形就是View的顯示區(qū)够傍,藍(lán)色圓形就是頭像了甫菠。已知的條件是View大小,姑且設(shè)為 D 吧王带,還有頭像的數(shù)量 n ,求藍(lán)色圓的半徑 r 及圓心位置市殷。這不就是一道幾何題嗎愕撰?翻開(kāi)初中的數(shù)學(xué)課本——勾三股四弦五……好像不夠用啊……

輔助線畫(huà)了又畫(huà),頭皮撓了又撓醋寝,α,θ,OMG......sin,cos,sh*t......終于算出了rDn的關(guān)系:

公式1

其實(shí) n=3 的時(shí)候半徑和 n=4 的時(shí)候是一樣的搞挣,但是考慮到 n=3,5 時(shí)在Y軸上還有一個(gè)偏移量 dy ,而且 r 和 dy 在 n=3,5 時(shí)是有通式的,所以就合在一起了音羞。求偏移量 dy 的公式:


公式2

式中 R 就是布局圖中紅色大圓的半徑囱桨。

有了公式,那么代碼就好寫(xiě)了嗅绰,計(jì)算每個(gè)頭像的大小和位置的代碼如下:

// 頭像信息類舍肠,記錄大小、位置等信息
private static class DrawableInfo {
    int mId = View.NO_ID;
    Drawable mDrawable;
    // 中心點(diǎn)位置
    float mCenterX;
    float mCenterY;
    // 頭像上缺口弧所在圓上的圓心位置窘面,其實(shí)就是下一個(gè)相鄰頭像的中心點(diǎn)
    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);
            // 以所有圖像圓為內(nèi)切圓的圓的半徑
            final float R = (float) (r * ((sinN + 1) / sinN));
            mOffsetY = (float) ((mContentSize - R - r * (1 + 1 / Math.tan(Math.PI / N))) / 2f);
        }

        // 初始化第一個(gè)頭像的中心位置
        final float startX, startY;
        if (N % 2 == 0) {
            startX = startY = r;
        } else {
            startX = center;
            startY = r;
        }

        // 變換矩陣
        final Matrix matrix = mLayoutMatrix;
        // 坐標(biāo)點(diǎn)臨時(shí)數(shù)組
        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) {
                // 以上一個(gè)圓的圓心旋轉(zhuǎn)計(jì)算得出當(dāng)前圓的圓位置
                matrix.postRotate(360.f / N, center, center + mOffsetY);
                matrix.mapPoints(pointsTemp);
            }

            // 取出中心點(diǎn)位置
            drawable.mCenterX = pointsTemp[0];
            drawable.mCenterY = pointsTemp[1];

            // 設(shè)置邊界
            drawable.mBounds.inset(-r, -r);
            drawable.mBounds.offset(drawable.mCenterX, drawable.mCenterY);

            // 設(shè)置“蒙板”路徑
            drawable.mMaskPath.addCircle(drawable.mCenterX, drawable.mCenterY, r, Path.Direction.CW);
            drawable.mMaskPath.setFillType(Path.FillType.INVERSE_WINDING);
        }

        // 設(shè)置第一個(gè)頭像的缺口财边,頭像數(shù)量少于3個(gè)的時(shí)候沒(méi)有
        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();
}

繪制

計(jì)算好每個(gè)頭像的大小和位置后肌括,就可以把它們繪制出來(lái)了。但在此之前酣难,還得先解決一個(gè)問(wèn)題——如何使頭像圖像變圓谍夭?因?yàn)檩斎隓rawable對(duì)象并沒(méi)有任何限制。在上面的layoutDrawables方法中有這樣兩行代碼:

drawable.mMaskPath.addCircle(drawable.mCenterX, drawable.mCenterY, r, Path.Direction.CW);
drawable.mMaskPath.setFillType(Path.FillType.INVERSE_WINDING);

其中第一行是添加一個(gè)圓形路徑憨募,這個(gè)路徑就是布局圖中藍(lán)色圓的路徑紧索,而第二行是設(shè)置路徑的填充模式,默認(rèn)的填充模式是填充路徑內(nèi)部菜谣,而INVERSE_WINDING模式是填充路徑外部齐板,再配合Paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR))就可以繪制出圓形的圖像了。頭像上的缺口同理。(ps:關(guān)于Path.FillType和PorterDuff.Mode網(wǎng)上介紹挺多的甘磨,這里就不詳細(xì)介紹了)

下面來(lái)看一下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);

        // 設(shè)置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對(duì)象,那就不能像Bitmap那樣繪制出來(lái)就完事了的济舆,除非你不打算支持Drawable的一些功能卿泽,如自更新、動(dòng)畫(huà)滋觉、狀態(tài)等签夭。

  • Drawable自更新和動(dòng)畫(huà)Drawable
    Drawable的自更新和動(dòng)畫(huà)Drawable(如AnimationDrawableAnimatedVectorDrawable等)都是依賴于Drawable.Callback接口椎侠。其定義如下:
    public interface Callback {
        /**
         * 當(dāng)drawable需要重新繪制時(shí)調(diào)用第租。此時(shí)的view應(yīng)該使其自身失效(至少drawable展示部分失效)
         * @param who 要求重新繪制的drawable
         */
        void invalidateDrawable(@NonNull Drawable who);
    
        /**
         * drawable可以通過(guò)調(diào)用該方法來(lái)安排動(dòng)畫(huà)的下一幀。
         * @param who 要預(yù)定的drawable
         * @param what 要執(zhí)行的動(dòng)作
         * @param when 執(zhí)行的時(shí)間(以毫秒為單位)我纪,基于android.os.SystemClock.uptimeMillis()
         */
        void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when);
    
        /**
         * drawable可以通過(guò)調(diào)用該方法來(lái)取消先前通過(guò)scheduleDrawable(Drawable, Runnable, long)調(diào)度的動(dòng)作慎宾。
         * @param who 要取消預(yù)定的drawable
         * @param what 要取消執(zhí)行的動(dòng)作
         */
        void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what);
    }
    
    所以要支持Drawable自更新和動(dòng)畫(huà)Drawable,得通過(guò) Drawable.setCallback(Drawable.Callback)方法設(shè)置Drawable.Callback接口的實(shí)現(xiàn)對(duì)象才行浅悉。好在android.view.View已經(jīng)實(shí)現(xiàn)了這個(gè)接口趟据,在設(shè)置Drawable的時(shí)候調(diào)用一下Drawable.setCallback(MyView.this)即可。但需要注意的是术健,android.view.View實(shí)現(xiàn)Drawable.Callback接口的時(shí)候都調(diào)用了View.verifyDrawable(Drawable)以驗(yàn)證需要顯示更新的Drawable是不是自己的Drawable汹碱,且其實(shí)現(xiàn)只是驗(yàn)證了View自己的背景和前景:
    protected boolean verifyDrawable(@NonNull Drawable who) {
        // ...
        return who == mBackground || (mForegroundInfo != null && mForegroundInfo.mDrawable == who);
    }
    
    所以只是設(shè)置了Callback的話,當(dāng)Drawable內(nèi)容改變需要重新繪制時(shí)View還是不會(huì)更新重繪的荞估,動(dòng)畫(huà)需要計(jì)劃下一幀或者取消一個(gè)計(jì)劃時(shí)也不會(huì)成功咳促。因此我們也得驗(yàn)證自己的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);
    }
    

此時(shí),Drawable自更新的支持和動(dòng)畫(huà)Drawable的支持基本上是完成了勘伺。當(dāng)然等缀,View不可見(jiàn)和onDetachedFromWindow()時(shí)應(yīng)該是要暫停或者停止動(dòng)畫(huà)的娇昙,這些在這里就不多說(shuō)了尺迂,可以去看源碼(在文章結(jié)尾處有鏈接),主要是調(diào)用Drawable.setVisible(boolean, boolean)方法冒掌。下面展示一下效果:

AnimationDrawable
  • 狀態(tài)
    一些Drawable是有狀態(tài)的噪裕,它能根據(jù)View的狀態(tài)(按下,選中股毫,激活等)改變其顯示內(nèi)容膳音,如StateListDrawable。要支持View狀態(tài)的話铃诬,其實(shí)只要擴(kuò)展 View.drawableStateChanged()View.jumpDrawablesToCurrentState() 方法祭陷,當(dāng)View的狀態(tài)改變的時(shí)候更新Drawable的狀態(tài)就行了:
    // 狀態(tài)改變時(shí)被調(diào)用
    @Override
    protected void drawableStateChanged() {
        super.drawableStateChanged();
        boolean invalidate = false;
        for (DrawableInfo drawable : mDrawables) {
            Drawable d = drawable.mDrawable;
            // 判斷Drawable是否支持狀態(tài)并更新?tīng)顟B(tài)
            if (d.isStateful() && d.setState(getDrawableState())) {
                invalidate = true;
            }
        }
        if (invalidate) {
            invalidate();
        }
    }
    
    // 這個(gè)方法主要針對(duì)狀態(tài)改變時(shí)有過(guò)渡動(dòng)畫(huà)的Drawable
    @Override
    public void jumpDrawablesToCurrentState() {
        super.jumpDrawablesToCurrentState();
        for (DrawableInfo drawable : mDrawables) {
            drawable.mDrawable.jumpToCurrentState();
        }
    }
    

    效果:


    狀態(tài)

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

效果1

效果2

源代碼:https://github.com/YiiGuxing/CompositionAvatar
我的GitHub:https://github.com/YiiGuxing
歡迎Star兵志,謝謝醇蝴!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市想罕,隨后出現(xiàn)的幾起案子悠栓,更是在濱河造成了極大的恐慌,老刑警劉巖按价,帶你破解...
    沈念sama閱讀 217,509評(píng)論 6 504
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件惭适,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡楼镐,警方通過(guò)查閱死者的電腦和手機(jī)癞志,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,806評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)框产,“玉大人凄杯,你說(shuō)我怎么就攤上這事∶┬牛” “怎么了盾舌?”我有些...
    開(kāi)封第一講書(shū)人閱讀 163,875評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵墓臭,是天一觀的道長(zhǎng)蘸鲸。 經(jīng)常有香客問(wèn)我,道長(zhǎng)窿锉,這世上最難降的妖魔是什么酌摇? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,441評(píng)論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮嗡载,結(jié)果婚禮上窑多,老公的妹妹穿的比我還像新娘。我一直安慰自己洼滚,他們只是感情好埂息,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,488評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著遥巴,像睡著了一般千康。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上铲掐,一...
    開(kāi)封第一講書(shū)人閱讀 51,365評(píng)論 1 302
  • 那天拾弃,我揣著相機(jī)與錄音,去河邊找鬼摆霉。 笑死豪椿,一個(gè)胖子當(dāng)著我的面吹牛奔坟,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播搭盾,決...
    沈念sama閱讀 40,190評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼咳秉,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了增蹭?” 一聲冷哼從身側(cè)響起滴某,我...
    開(kāi)封第一講書(shū)人閱讀 39,062評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎滋迈,沒(méi)想到半個(gè)月后霎奢,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,500評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡饼灿,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,706評(píng)論 3 335
  • 正文 我和宋清朗相戀三年幕侠,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片碍彭。...
    茶點(diǎn)故事閱讀 39,834評(píng)論 1 347
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡晤硕,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出庇忌,到底是詐尸還是另有隱情舞箍,我是刑警寧澤,帶...
    沈念sama閱讀 35,559評(píng)論 5 345
  • 正文 年R本政府宣布皆疹,位于F島的核電站疏橄,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏略就。R本人自食惡果不足惜捎迫,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,167評(píng)論 3 328
  • 文/蒙蒙 一宣脉、第九天 我趴在偏房一處隱蔽的房頂上張望嵌言。 院中可真熱鬧,春花似錦成洗、人聲如沸崔兴。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,779評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)敲茄。三九已至位谋,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間折汞,已是汗流浹背倔幼。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 32,912評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留爽待,地道東北人损同。 一個(gè)月前我還...
    沈念sama閱讀 47,958評(píng)論 2 370
  • 正文 我出身青樓翩腐,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親膏燃。 傳聞我的和親對(duì)象是個(gè)殘疾皇子茂卦,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,779評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容