Android自定义控件 | 小红点的三种实现(下)

此文标题想了好久久久,本起名为《读原码长知识 | 小红点的一种实现》,但纠结了下,觉得还是应该隶属于自定义控件系列~~

上篇介绍了两种实现小红点的方案,分别是多控件叠加和单控件绘制,其中第二个方案有一个缺点:类型绑定。导致它无法被不同类型控件所复用。这篇从父控件的角度出发,提出一个新的方案:容器控件绘制,以突破类型绑定。

这是自定义控件系列教程的第六篇,系列文章目录如下:

  1. Android自定义控件 | View绘制原理(画多大?)
  2. Android自定义控件 | View绘制原理(画在哪?)
  3. Android自定义控件 | View绘制原理(画什么?)
  4. Android自定义控件 | 源码里有宝藏之自动换行控件
  5. Android自定义控件 | 小红点的三种实现(上)
  6. Android自定义控件 | 小红点的三种实现(下)
  7. Android自定义控件 | 小红点的三种实现(终结)

本文使用 Kotlin 编写,相关系列教程可以点击这里

引子

假设这样一个场景:一个容器控件中,有三种不同类型的控件需要在右上角显示小红点。若使用上一篇中的“单控件绘制方案”,就必须自定义三种不同类型的控件,在其矩形区域的右上角绘制小红点。

可不可以把绘制工作交给容器控件?

容器控件能轻而易举地知道子控件矩形区域的坐标,有什么办法把“哪些孩子需要绘制小红点”告诉容器控件,以让其在相应位置绘制?

在读androidx.constraintlayout.helper.widget.Layer源码时,发现它用一种巧妙的方式将子控件的信息告诉容器控件。

Layer的启发

绑定关联控件

Layer是一个配合ConstraintLayout使用的控件,可实现如下效果:

image

即在不增加布局层级的情况下,为一组子控件设置背景,代码如下:




    

LayerButton平级,只使用了属性app:constraint_referenced_ids="btn3,btn4,btn5"标记关联控件就能为其添加背景,很好奇是怎么做到的,点开源码:

public class Layer extends ConstraintHelper {}

public abstract class ConstraintHelper extends View {}

LayerConstraintHelper的子类,而ConstraintHelper是自定义View。所以它可以在 xml 中被声明为ConstraintLayout的子控件。

想必ConstraintLayout遍历子控件时会将ConstraintHelper存储起来。在ConstraintLayout中搜索ConstraintHelper,果不其然:

public class ConstraintLayout extends ViewGroup {
    //'存储ConstraintHelper的列表'
    private ArrayList mConstraintHelpers = new ArrayList(4);
    
    //'当子控件被添加到容器时该方法被调用'
    public void onViewAdded(View view) {
        ...
        //'存储ConstraintHelper类型的子控件'
        if (view instanceof ConstraintHelper) {
            ConstraintHelper helper = (ConstraintHelper)view;
            helper.validateParams();
            ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams)view.getLayoutParams();
            layoutParams.isHelper = true;
            if (!this.mConstraintHelpers.contains(helper)) {
                this.mConstraintHelpers.add(helper);
            }
        }
        ...
    }
}

有添加必有移除,应该有一个和onViewAdded()对应的方法:

public class ConstraintLayout extends ViewGroup {
    //'当子控件被移除到容器时该方法被调用'
    public void onViewRemoved(View view) {
        ...
        this.mChildrenByIds.remove(view.getId());
        ConstraintWidget widget = this.getViewWidget(view);
        this.mLayoutWidget.remove(widget);
        //'将ConstraintHelper子控件移除'
        this.mConstraintHelpers.remove(view);
        this.mVariableDimensionsWidgets.remove(widget);
        this.mDirtyHierarchy = true;
    }
}

除了这两处,ConstraintLayout中和ConstraintHelper相关的代码并不多:

public class ConstraintLayout extends ViewGroup {
    private void setChildrenConstraints() {
        ...
        helperCount = this.mConstraintHelpers.size();
        int i;
        if (helperCount > 0) {
            for(i = 0; i < helperCount; ++i) {
                ConstraintHelper helper = (ConstraintHelper)this.mConstraintHelpers.get(i);
                //'遍历所有ConstraintHelper通知布局前更新'
                helper.updatePreLayout(this);
            }
        }
        ...
    }
    
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        ...
        helperCount = this.mConstraintHelpers.size();
        if (helperCount > 0) {
            for(int i = 0; i < helperCount; ++i) {
                ConstraintHelper helper = (ConstraintHelper)this.mConstraintHelpers.get(i);
                //'遍历所有ConstraintHelper通知布局后更新'
                helper.updatePostLayout(this);
            }
        }
        ...
    }
    public final void didMeasures() {
            ...
            helperCount = this.layout.mConstraintHelpers.size();
            if (helperCount > 0) {
                for(int i = 0; i < helperCount; ++i) {
                    ConstraintHelper helper = (ConstraintHelper)this.layout.mConstraintHelpers.get(i);
                    //'遍历所有ConstraintHelper通知测量后更新'
                    helper.updatePostMeasure(this.layout);
                }
            }
            ...
    }
}

都是在各种时机通知ConstraintHelper做各种事情,这些事情和它的关联控件有关,具体做什么由ConstraintHelper子类决定。

获取关联控件

ConstraintHelper在 xml 中使用constraint_referenced_ids属性来关联控件,代码中是如何解析该属性的?

public abstract class ConstraintHelper extends View {
    //'关联控件id'
    protected int[] mIds = new int[32];
    //'关联控件引用'
    private View[] mViews = null;
    
    public ConstraintHelper(Context context) {
        super(context);
        this.myContext = context;
        //'初始化'
        this.init((AttributeSet)null);
    }
    
    protected void init(AttributeSet attrs) {
        if (attrs != null) {
            TypedArray a = this.getContext().obtainStyledAttributes(attrs, styleable.ConstraintLayout_Layout);
            int N = a.getIndexCount();

            for(int i = 0; i < N; ++i) {
                int attr = a.getIndex(i);
                //'获取constraint_referenced_ids属性值'
                if (attr == styleable.ConstraintLayout_Layout_constraint_referenced_ids) {
                    this.mReferenceIds = a.getString(attr);
                    this.setIds(this.mReferenceIds);
                }
            }
        }
    }
    
    private void setIds(String idList) {
        if (idList != null) {
            int begin = 0;
            this.mCount = 0;

            while(true) {
                //'将关联控件id按逗号分隔'
                int end = idList.indexOf(44, begin);
                if (end == -1) {
                    this.addID(idList.substring(begin));
                    return;
                }

                this.addID(idList.substring(begin, end));
                begin = end + 1;
            }
        }
    }
    
    private void addID(String idString) {
        if (idString != null && idString.length() != 0) {
            if (this.myContext != null) {
                idString = idString.trim();
                int rscId = 0;
                
                //'获取关联控件id的Int值'
                try {
                    Class res = id.class;
                    Field field = res.getField(idString);
                    rscId = field.getInt((Object)null);
                } catch (Exception var5) {
                }
                ...

                if (rscId != 0) {
                    this.mMap.put(rscId, idString);
                    //'将关联控件id加入数组'
                    this.addRscID(rscId);
                } 
                ...
            }
        }
    }
    
    private void addRscID(int id) {
        if (this.mCount + 1 > this.mIds.length) {
            this.mIds = Arrays.copyOf(this.mIds, this.mIds.length * 2);
        }
        //'将关联控件id加入数组'
        this.mIds[this.mCount] = id;
        ++this.mCount;
    }
}

ConstraintHelper先读取自定义属性constraint_referenced_ids的值,然后将其按逗号分隔并转换成 int 值,最终存在int[] mIds中。这样做的目的是为了在必要时获取关联控件 View 的实例:

public abstract class ConstraintHelper extends View {
    protected View[] getViews(ConstraintLayout layout) {
        if (this.mViews == null || this.mViews.length != this.mCount) {
            this.mViews = new View[this.mCount];
        }
        //'遍历关联控件id数组'
        for(int i = 0; i < this.mCount; ++i) {
            int id = this.mIds[i];
            //'将id转换成View并存入数组'
            this.mViews[i] = layout.getViewById(id);
        }

        return this.mViews;
    }
}

public class ConstraintLayout extends ViewGroup {
    //'ConstraintLayout暂存子控件的数组'
    SparseArray mChildrenByIds = new SparseArray();
    public View getViewById(int id) {
        return (View)this.mChildrenByIds.get(id);
    }

ConstraintHelper.getViews()遍历关联控件 id 数组并通过父控件获得关联控件 View 。

应用关联控件

ConstraintHelper.getViews()protected方法,这意味着ConstraintHelper的子类会用到这个方法,去Layer里看一下:

public class Layer extends ConstraintHelper {
    protected void calcCenters() {
                    ...
                    View[] views = this.getViews(this.mContainer);
                    int minx = views[0].getLeft();
                    int miny = views[0].getTop();
                    int maxx = views[0].getRight();
                    int maxy = views[0].getBottom();
                    
                    //'遍历关联控件'
                    for(int i = 0; i < this.mCount; ++i) {
                        View view = views[i];
                        //'记录关联控件控件的边界'
                        minx = Math.min(minx, view.getLeft());
                        miny = Math.min(miny, view.getTop());
                        maxx = Math.max(maxx, view.getRight());
                        maxy = Math.max(maxy, view.getBottom());
                    }
                    
                    //'将关联控件边界记录在成员变量中'
                    this.mComputedMaxX = (float)maxx;
                    this.mComputedMaxY = (float)maxy;
                    this.mComputedMinX = (float)minx;
                    this.mComputedMinY = (float)miny;
                    ...
    }
}

Layer在获得关联控件边界值之后,会在layout的时候以此为依据确定自己的矩形区域:

public class Layer extends ConstraintHelper {
    public void updatePostLayout(ConstraintLayout container) {
        ...
        this.calcCenters();
        int left = (int)this.mComputedMinX - this.getPaddingLeft();
        int top = (int)this.mComputedMinY - this.getPaddingTop();
        int right = (int)this.mComputedMaxX + this.getPaddingRight();
        int bottom = (int)this.mComputedMaxY + this.getPaddingBottom();
        //'确定自己的矩形区域'
        this.layout(left, top, right, bottom);
        if (!Float.isNaN(this.mGroupRotateAngle)) {
            this.transform();
        }
    }
}

这就是为啥Layer可以为一组关联控件设置背景的原因。

ConstraintHelperConstraintLayout子控件的身份出现在布局文件中,它通过自定义属性来关联同级的其他控件,它就好像一个标记,当父控件遇到标记时,就能为被标记的控件做一些特殊的事情,比如“为一组子控件添加背景”,而这些特殊的事情就定义在ConstraintHelper的子类中。

自定义容器控件

我们不是正在寻找“如何把哪些子控件需要绘制小红点告诉父控件”的方法吗?借用ConstraintHelper的思想方法就能实现。实现成功之后的布局文件应该长这样(伪码):



    

    

其中的TreasureBoxRedPointTreasure就是我们要实现的自定义容器控件和标记控件。

仿照ContraintLayout写一个自定义容器控件:

class TreasureBox @JvmOverloads 
    constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    ConstraintLayout(context, attrs, defStyleAttr) {
    //'标记控件列表'
    private var treasures = mutableListOf()
    init {
        //'这行代码是必须的,否则不能在容器控件画布绘制图案'
        setWillNotDraw(false)
    }
    
    //'当子控件被添加时,过滤出标记控件并保存引用'
    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 onDrawForeground(canvas: Canvas?) {
        super.onDrawForeground(canvas)
        treasures.forEach { treasure -> treasure.drawTreasure(this, canvas) }
    }
}

因为小红点是绘制在容器控件画布上的,所以在初始化时必须调用setWillNotDraw(false),该函数用于控件当前视图是否会绘制:

public class View {
    
    //'控件设置了这个flag,则表示它不会自己绘制'
    static final int WILL_NOT_DRAW = 0x00000080;
    
    //'如果视图自己不绘制内容,则可以将这个flag为false'
    public void setWillNotDraw(boolean willNotDraw) {
        setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW_MASK);
    }
}

而容器控件ViewGroup默认将其设为了 false :

public abstract class ViewGroup extends View {
    private void initViewGroup() {
        // ViewGroup doesn’t draw by default
        //'默认情况下,容器控件都不会在自己画布上绘制'
        if (!debugDraw()) {
            setFlags(WILL_NOT_DRAW, DRAW_MASK);
        }
        ...
    }
}

一开始想当然地把绘制逻辑写在了onDraw()函数中,虽然也可以绘制出小红点,但当子控件设置背景色时,小红点就被覆盖了,回看源码才发现,onDraw()绘制的是控件自身的内容,而绘制子控件内容的dispatchDraw()在它之后,越晚绘制的就在越上层:

public class View {
    public void draw(Canvas canvas) {
        ...
        if (!verticalEdges && !horizontalEdges) {
            //'绘制自己'
            onDraw(canvas);

            //'绘制孩子'
            dispatchDraw(canvas);

            drawAutofilledHighlight(canvas);

            // Overlay is part of the content and draws beneath Foreground
            if (mOverlay != null && !mOverlay.isEmpty()) {
                mOverlay.getOverlayView().dispatchDraw(canvas);
            }

            //'绘制前景'
            onDrawForeground(canvas);

            // Step 7, draw the default focus highlight
            drawDefaultFocusHighlight(canvas);

            if (debugDraw()) {
                debugDrawFocus(canvas);
            }

            return;
        }
        ...
    }

绘制前景在绘制孩子之后,所以在onDrawForeground()中绘制可以保证小红点不会被子控件覆盖。关于控件绘制的详细解析可以点击这里。

自定义标记控件

接着模仿ConstraintHelper写一个自定义标记控件:

abstract class Treasure @JvmOverloads 
    constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : 
    View(context, attrs, defStyleAttr) {
    //'用于存放关联id的列表'
    internal var ids = mutableListOf()
    //'在构造时解析自定义数据'
    init {
        readAttrs(attrs)
    }

    //'标记控件绘制具体内容的地方,供子类实现(canvas是容器控件的画布)'
    abstract fun drawTreasure(treasureBox: TreasureBox, canvas: Canvas?) 

    //'解析自定义属性“关联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解析成int值,以便通过findViewById()获取控件引用'
    private fun divideIds(idString: String?) {
        idString?.split(",")?.forEach { id ->
            ids.add(resources.getIdentifier(id.trim(), "id", context.packageName))
        }
    }
}

这个是自定义标记控件的基类,这层抽象只是用来解析标记控件的基础属性“关联id”,定义如下:



    
        
    

绘制函数是抽象的,具体的绘制逻辑交给子类实现:

class RedPointTreasure @JvmOverloads 
    constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    Treasure(context, attrs, defStyleAttr) {
    
    private val DEFAULT_RADIUS = 5F
    //'小红点圆心x偏移量'
    private lateinit var offsetXs: MutableList
    //'小红点圆心y偏移量'
    private lateinit var offsetYs: MutableList
    //'小红点半径'
    private lateinit var radiuses: MutableList
    //'小红点画笔'
    private var bgPaint: Paint = Paint()

    init {
        initPaint()
    }
    
    //'初始化画笔'    
    private fun initPaint() {
        bgPaint.apply {
            isAntiAlias = true
            style = Paint.Style.FILL
            color = Color.parseColor("#ff0000")
        }
    }

    //'解析自定义属性'
    override fun readAttrs(attributeSet: AttributeSet?) {
        super.readAttrs(attributeSet)
        attributeSet?.let { attrs ->
            context.obtainStyledAttributes(attrs, R.styleable.RedPointTreasure)?.let {
                divideRadiuses(it.getString(R.styleable.RedPointTreasure_reference_radius))
                dividerOffsets(
                    it.getString(R.styleable.RedPointTreasure_reference_offsetX),
                    it.getString(R.styleable.RedPointTreasure_reference_offsetY)
                )
                it.recycle()
            }
        }
    }

    //'小红点绘制逻辑'
    override fun drawTreasure(treasureBox: TreasureBox, canvas: Canvas?) {
        //'遍历关联id列表'
        ids.forEachIndexed { index, id ->
            treasureBox.findViewById(id)?.let { v ->
                val cx = v.right + offsetXs.getOrElse(index) { 0F }.dp2px()
                val cy = v.top + offsetYs.getOrElse(index) { 0F }.dp2px()
                val radius = radiuses.getOrElse(index) { DEFAULT_RADIUS }.dp2px()
                canvas?.drawCircle(cx, cy, radius, bgPaint)
            }
        }
    }

    //'解析偏移量'
    private fun dividerOffsets(offsetXString: String?, offsetYString: String?) {
        offsetXs = mutableListOf()
        offsetYs = mutableListOf()
        offsetXString?.split(",")?.forEach { offset -> offsetXs.add(offset.trim().toFloat()) }
        offsetYString?.split(",")?.forEach { offset -> offsetYs.add(offset.trim().toFloat()) }
    }

    //'解析半径'
    private fun divideRadiuses(radiusString: String?) {
        radiuses = mutableListOf()
        radiusString?.split(",")?.forEach { radius -> radiuses.add(radius.trim().toFloat()) }
    }
    
    //'小红点尺寸多屏幕适配'
    private fun Float.dp2px(): Float {
        val scale = Resources.getSystem().displayMetrics.density
        return this * scale + 0.5f
    }
}

解析的自定义属性如下:



    
        
        
        
    

然后就可以在 xml 文件中完成小红点的绘制,效果图如下:

Android自定义控件 | 小红点的三种实现(下)_第1张图片
image

xml 定义如下:




    

    

业务层通常需要动态改变小红点的显示状态,为RedPointTreasure增加一个接口:

class RedPointTreasure @JvmOverloads 
    constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    Treasure(context, attrs, defStyleAttr) {
    
    companion object {
        @JvmStatic
        val TYPE_RADIUS = "radius"
        @JvmStatic
        val TYPE_OFFSET_X = "offset_x"
        @JvmStatic
        val TYPE_OFFSET_Y = "offset_y"
    }
    
    //'为指定关联控件设置自定义属性'
    fun setValue(id: Int, type: String, value: Float) {
        val dirtyIndex = ids.indexOf(id)
        if (dirtyIndex != -1) {
            when (type) {
                TYPE_OFFSET_X -> offsetXs[dirtyIndex] = value
                TYPE_OFFSET_Y -> offsetYs[dirtyIndex] = value
                TYPE_RADIUS -> radiuses[dirtyIndex] = value
            }
            //'触发父控件的重绘'
            (parent as? TreasureBox)?.postInvalidate()
        }
    }
}

如果要隐藏小红点,只需要将半径设置为0:

redPoint?.setValue(R.id.tv, RedPointTreasure.TYPE_RADIUS, 0f)

这套容器控件+标记控件的组合除了可以绘制小红点,还可以做其他很多事情。这是一套子控件和父控件相互通信的方式。

talk is cheap, show me the code

完整的源码可以点击这里

推荐阅读

这也是读源码长知识系列的第三篇,该系列的特点是将源码中的设计思想运用到真实项目之中,系列文章目录如下:

  1. 读源码长知识 | 更好的RecyclerView点击监听器

  2. Android自定义控件 | 源码里有宝藏之自动换行控件

  3. Android自定义控件 | 小红点的三种实现(下)

  4. 读源码长知识 | 动态扩展类并绑定生命周期的新方式

  5. 读源码长知识 | Android卡顿真的是因为”掉帧“?

你可能感兴趣的:(Android自定义控件 | 小红点的三种实现(下))