Android自定義控件 | 小紅點的三種實現(終結)

上一篇經過在父控件繪製前景的方式展現小紅點,在佈局文件中配置標記控件就能爲任意子控件添加小紅點。實現方案是」佈局文件中配置帶小紅點控件 id,在父控件中獲取它們的座標,並在其右上角繪製圓圈「。但這個方案有一個漏洞,當子控件作動畫,即子控件尺寸發生變化時,小紅點不會聯動。效果入下圖:android

因此新的課題是: 如何在父控件中監聽子控件重繪並做出響應?

本文是系列文章的第七篇,系列文章以下:git

  1. Android自定義控件 | View繪製原理(畫多大?)
  2. Android自定義控件 | View繪製原理(畫在哪?)
  3. Android自定義控件 | View繪製原理(畫什麼?)
  4. Android自定義控件 | 源碼裏有寶藏之自動換行控件
  5. Android自定義控件 | 小紅點的三種實現(上)
  6. Android自定義控件 | 小紅點的三種實現(下)
  7. Anndroid自定義控件 | 小紅點的三種實現(終結)

監聽重繪

在父控件的draw()dispatchDraw()drawChild()中打 log,子控件作動畫時都未能捕獲到聯動的事件。github

忽然想起androidx.coordinatorlayout.widget.CoordinatorLayout中的Behavior,在onDependentViewChanged()中能夠實時得到關聯控件的屬性變化。它是如何作到的?沿着調用鏈往上查找:canvas

public class CoordinatorLayout extends ViewGroup{
    final void onChildViewsChanged(@DispatchChangeEvent final int type) {
        final int childCount = mDependencySortedChildren.size();

        for (int i = 0; i < childCount; i++) {
            final View child = mDependencySortedChildren.get(i);

            //'遍歷全部依賴的子控件'
            for (int j = i + 1; j < childCount; j++) {
                final View checkChild = mDependencySortedChildren.get(j);
                ...
                if (b != null && b.layoutDependsOn(this, checkChild, child)) {
                    ...
                    final boolean handled;
                    switch (type) {
                        case EVENT_VIEW_REMOVED:
                            // EVENT_VIEW_REMOVED means that we need to dispatch
                            // onDependentViewRemoved() instead
                            b.onDependentViewRemoved(this, checkChild, child);
                            handled = true;
                            break;
                        default:
                            //'將子控件變化傳遞出去'
                            handled = b.onDependentViewChanged(this, checkChild, child);
                            break;
                    }
                    ...
                }
        }
    }
}
複製代碼

當關聯子控件發生變化時,會遍歷關聯控件並將變換經過onDependentViewChanged()傳遞出去。沿着調用鏈再往上:bash

public class CoordinatorLayout extends ViewGroup{
    class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener {
        @Override
        public boolean onPreDraw() {
            //'在 onPreDraw() 中捕獲子控件屬性變化事件'
            onChildViewsChanged(EVENT_PRE_DRAW);
            return true;
        }
    }
    
    @Override
    public void onAttachedToWindow() {
        super.onAttachedToWindow();
        if (mNeedsPreDrawListener) {
            if (mOnPreDrawListener == null) {
                //'在 onAttachedToWindow() 中構建PreDrawListener'
                mOnPreDrawListener = new OnPreDrawListener();
            }
            final ViewTreeObserver vto = getViewTreeObserver();
            //'註冊 View 樹觀察者'
            vto.addOnPreDrawListener(mOnPreDrawListener);
        }
    }
}

//'全局 View 樹觀察者'
public final class ViewTreeObserver {
    public interface OnPreDrawListener {
        //'view 樹被繪製前該接口被調用,此時 view 樹中全部視圖已經被 measure 和 layout '
        public boolean onPreDraw();
    }
}
複製代碼

CoordinatorLayoutonAttachedToWindow()時註冊了 View 樹觀察者,子控件屬性變化時一定會觸發 View樹重繪,這樣就能夠在onPreDraw()中監聽到它們的屬性變化。dom

將這套機制照搬到自定義容器控件TreasureBoxide

//自定義容器控件,需配合標記控件使用
class TreasureBox @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    ConstraintLayout(context, attrs, defStyleAttr) {
    //'標記控件列表,用於標記哪些子控件須要小紅點'
    private var treasures = mutableListOf<Treasure>()
    //'View 樹觀察者'
    private var onPreDrawListener: ViewTreeObserver.OnPreDrawListener = ViewTreeObserver.OnPreDrawListener {
        //'View 樹重繪前通知全部標記控件'
        treasures.forEach { treasure -> treasure.onPreDraw(this) }
        true
    }

    override fun onViewAdded(child: View?) {
        super.onViewAdded(child)
        //存儲標記控件
        (child as? Treasure)?.let { treasure ->
            treasures.add(treasure)
        }
    }

    override fun onViewRemoved(child: View?) {
        super.onViewRemoved(child)
        //移除標記控件
        (child as? Treasure)?.let { treasure ->
            treasures.remove(treasure)
        }
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        //'註冊 View 樹監聽器'
        viewTreeObserver.addOnPreDrawListener(onPreDrawListener)
    }

複製代碼

這樣當須要繪製小紅點的子控件屬性發生變化時,標記控件就能夠在onPreDraw()中收到通知:函數

//'抽象標記控件'
abstract class Treasure @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    View(context, attrs, defStyleAttr) {
    //'關聯控件 id 列表'
    internal var ids = mutableListOf<Int>()

    fun onPreDraw(treasureBox: TreasureBox) {
        ids.map { treasureBox.findViewById<View>(it) }.forEach { v ->
            //'這裏能夠監聽到關聯子控件屬性變化'
        }
    }
複製代碼

子控件重繪帶動父控件重繪

每次 View 樹重繪前均可以在onPreDraw()中實時獲取子控件的寬高及座標,爲了不過分重繪,只有當屬性變化時,才觸發父控件重繪。須要記憶上次重繪的屬性,經過比較就能知道屬性是否發生變動:佈局

abstract class Treasure @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    View(context, attrs, defStyleAttr) {
    //'關聯控件屬性,與關聯控件id列表一一對應'
    var layoutParams = mutableListOf<LayoutParam>()
    //'關聯控件id列表'
    internal var ids = mutableListOf<Int>()
        
    fun onPreDraw(treasureBox: TreasureBox) {
        //'在關聯控件重繪前,遍歷它們檢查其屬性是否變動'
        ids.forEachIndexed { index, id ->
            treasureBox.findViewById<View>(id)?.let { v ->
                LayoutParam(v.width, v.height, v.x, v.y).let { lp ->
                    //'若關聯控件屬性變動,觸發父控件重繪'
                    if (layoutParams[index] != lp) {
                        if (layoutParams[index].isValid()) {
                            treasureBox.postInvalidate()
                        }
                        layoutParams[index] = lp
                    }
                }
            }
        }
    }
        
    //'控件屬性實體類,儲存寬高和座標'
    data class LayoutParam(var width: Int = 0, var height: Int = 0, var x: Float = 0f, var y: Float = 0f) {
        private var id: Int? = null
        override fun equals(other: Any?): Boolean {
            if (other == null || other !is LayoutParam) return false
            //'只有全部屬性都同樣,才認爲屬性沒有變動'
            return width == other.width && height == other.height && x == other.x && y == other.y
        }

        fun isValid() = width != 0 && height != 0
    }
}
複製代碼

還須要變動下小紅點繪製邏輯,以前的邏輯以下:post

//'小紅點標記控件'
class RedPointTreasure @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    Treasure(context, attrs, defStyleAttr) {
    
    override fun drawTreasure(treasureBox: TreasureBox, canvas: Canvas?) {
        //'遍歷關聯控件,並在父控件畫布上對應位置繪製小紅點'
        ids.forEachIndexed { index, id ->
            treasureBox.findViewById<View>(id)?.let { v ->
                //'經過關聯控件的 right 值,決定小紅點橫座標'
                val cx = v.right + v.width + offsetXs.getOrElse(index) { 0F }.dp2px()
                //'經過關聯控件的 top 值,決定小紅點縱座標'
                val cy = v.top + offsetYs.getOrElse(index) { 0F }.dp2px()
                val radius = radiuses.getOrElse(index) { DEFAULT_RADIUS }.dp2px()
                canvas?.drawCircle(cx, cy, radius, bgPaint)
            }
        }
    }
}
複製代碼

若是沿用這套繪製邏輯,即便父控件監聽到子控件重繪,小紅點也不會跟着聯動。那是由於 View 的getTop()getRight()不包含位移值:

public class View{
    public final int getTop() {
        return mTop;
    }
    
    public final int getRight() {
        return mRight;
    }
}
複製代碼

getX()getY()則包含了位移值:

public class View{
    public float getX() {
        return mLeft + getTranslationX();
    }
    
    public float getY() {
        return mTop + getTranslationY();
    }
}
複製代碼

只須要將繪製邏輯中的v.rightv.top換成v.xv.y,小紅點就能和動畫聯動了。爲控件添加位移和縮放動畫,測試一下:

GG思密達~ 。位移動畫的確會聯動,但縮放並無~

打了 log 才發現,View 經過setScale()的方式進行動畫時,它的寬高和座標並不會發生變化。。。

但必然是有一個屬性的值變化了,雖然暫且不知道它是啥?

只能打開View源碼,遍歷全部get開頭的函數,而後把它們的值打印在onPreDraw()中。通過屢次嘗試,終於找到了一個函數,它的返回值和子控件縮放動畫聯動:

public class View{
    public void getHitRect(Rect outRect) {
        if (hasIdentityMatrix() || mAttachInfo == null) {
            outRect.set(mLeft, mTop, mRight, mBottom);
        } else {
            final RectF tmpRect = mAttachInfo.mTmpTransformRect;
            tmpRect.set(0, 0, getWidth(), getHeight());
            //'將 matrix 值考慮在內'
            getMatrix().mapRect(tmpRect)
            outRect.set((int) tmpRect.left + mLeft, (int) tmpRect.top + mTop,
                    (int) tmpRect.right + mLeft, (int) tmpRect.bottom + mTop);
        }
    }
}
複製代碼

當子控件作縮小動畫時,該函數返回的Rect中的left會變大而right會變小。

函數的返回值在mLeft,mRight,mTop,mBottom的基礎上疊加了matrix的值。作動畫的屬性值最終都會反映到matrix上,這樣一分析好像能自圓其說,即該函數會實時返回 view 因動畫而改變的屬性值。

如此一來,只須要記憶上一次的Rect,就能在下次重繪前經過比較得知子控件是否作了動畫:

//標記控件
abstract class Treasure @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    View(context, attrs, defStyleAttr) {
    //關聯子控件id列表
    internal var ids = mutableListOf<Int>()
    //'關聯子控件當前幀區域列表'
    var rects = mutableListOf<Rect>()
    //'關聯子控件上一幀區域列表'
    var lastRects = mutableListOf<Rect>()
    
    fun onPreDraw(treasureBox: TreasureBox) {
        //'遍歷關聯控件'
        ids.forEachIndexed { index, id ->
            treasureBox.findViewById<View>(id)?.let { v ->
                //'得到當前幀控件區域'
                v.getHitRect(rects[index])
                //'若當前幀控件區域變動,則通知父控件重繪'
                if (rects[index] != lastRects[index]) {
                    treasureBox.postInvalidate()
                    //'更新上一幀控件區域'
                    lastRects[index].set(rects[index])
                }
            }
        }
    }
    
    //解析 xml 讀取關聯子控件id
    open fun readAttrs(attributeSet: AttributeSet?) {
        attributeSet?.let { attrs ->
            context.obtainStyledAttributes(attrs, R.styleable.Treasure)?.let {
                divideIds(it.getString(R.styleable.Treasure_reference_ids))
                it.recycle()
            }
        }
    }

    //'分割關聯子控件id字串'
    private fun divideIds(idString: String?) {
        idString?.split(",")?.forEach { id ->
            ids.add(resources.getIdentifier(id.trim(), "id", context.packageName))
            //'爲每一個關聯子控件初始化當前幀區域'
            rects.add(Rect())
            //'爲每一個關聯子控件初始化上一幀區域'
            lastRects.add(Rect())
        }
        ids.toCollection(mutableListOf()).print("ids") { it.toString() }
    }
}
複製代碼

繪製小紅點邏輯也要作響應改動:

class RedPointTreasure @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    Treasure(context, attrs, defStyleAttr) {

    //'在父控件畫布的前景上繪製小紅點'
    override fun drawTreasure(treasureBox: TreasureBox, canvas: Canvas?) {
        ids.forEachIndexed { index, id ->
            treasureBox.findViewById<View>(id)?.let { v ->
                //'小紅點圓心橫座標依賴於當前幀區域右邊界'
                val cx = rects[index].right + offsetXs.getOrElse(index) { 0F }.dp2px()
                //'小紅點圓心縱座標依賴於當前幀區域上邊界'
                val cy = rects[index].top + offsetYs.getOrElse(index) { 0F }.dp2px()
                val radius = radiuses.getOrElse(index) { DEFAULT_RADIUS }.dp2px()
                canvas?.drawCircle(cx, cy, radius, bgPaint)
            }
        }
    }
複製代碼

大功告成,效果以下:

talk is cheap, show me the code

相關文章
相關標籤/搜索