Android自定义控件|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使用的控件,可实现如下效果:
Android自定义控件|Android自定义控件 | 小红点的三种实现(下)
文章图片
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自定义控件|Android自定义控件 | 小红点的三种实现(下)
文章图片
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自定义控件 | 小红点的三种实现(下)】读源码长知识 | Android卡顿真的是因为”掉帧“?

    推荐阅读