此文标题想了好久久久,本起名为《读原码长知识 | 小红点的一种实现》,但纠结了下,觉得还是应该隶属于自定义控件系列~~
上篇介绍了两种实现小红点的方案,分别是多控件叠加和单控件绘制,其中第二个方案有一个缺点:类型绑定。导致它无法被不同类型控件所复用。这篇从父控件的角度出发,提出一个新的方案:容器控件绘制,以突破类型绑定。
这是自定义控件系列教程的第六篇,系列文章目录如下:
本文使用 Kotlin 编写,相关系列教程可以点击这里
引子
假设这样一个场景:一个容器控件中,有三种不同类型的控件需要在右上角显示小红点。若使用上一篇中的“单控件绘制方案”,就必须自定义三种不同类型的控件,在其矩形区域的右上角绘制小红点。
可不可以把绘制工作交给容器控件?
容器控件能轻而易举地知道子控件矩形区域的坐标,有什么办法把“哪些孩子需要绘制小红点”告诉容器控件,以让其在相应位置绘制?
在读androidx.constraintlayout.helper.widget.Layer
源码时,发现它用一种巧妙的方式将子控件的信息告诉容器控件。
Layer的启发
绑定关联控件
Layer
是一个配合ConstraintLayout
使用的控件,可实现如下效果:
即在不增加布局层级的情况下,为一组子控件设置背景,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/btn3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="btn3"
app:layout_constraintEnd_toStartOf="@id/btn4"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/btn4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="btn4"
app:layout_constraintEnd_toStartOf="@id/btn5"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toEndOf="@id/btn3"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/btn5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="btn5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toEndOf="@id/btn4"
app:layout_constraintTop_toTopOf="parent" />
//'为3个button添加背景'
<androidx.constraintlayout.helper.widget.Layer
android:id="@+id/layer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#0000ff"
//'关联3个button'
app:constraint_referenced_ids="btn3,btn4,btn5"
app:layout_constraintEnd_toEndOf="@id/btn5"
app:layout_constraintTop_toTopOf="@id/btn3"
app:layout_constraintBottom_toBottomOf="@id/btn3"
app:layout_constraintStart_toStartOf="@id/btn3"/>
</androidx.constraintlayout.widget.ConstraintLayout>
Layer
和Button
平级,只使用了属性app:constraint_referenced_ids="btn3,btn4,btn5"
标记关联控件就能为其添加背景,很好奇是怎么做到的,点开源码:
public class Layer extends ConstraintHelper {}
public abstract class ConstraintHelper extends View {}
Layer
是ConstraintHelper
的子类,而ConstraintHelper
是自定义View
。所以它可以在 xml 中被声明为ConstraintLayout
的子控件。
想必ConstraintLayout
遍历子控件时会将ConstraintHelper
存储起来。在ConstraintLayout
中搜索ConstraintHelper
,果不其然:
public class ConstraintLayout extends ViewGroup {
//'存储ConstraintHelper的列表'
private ArrayList<ConstraintHelper> 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<View> 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
可以为一组关联控件设置背景的原因。
ConstraintHelper
以ConstraintLayout
子控件的身份出现在布局文件中,它通过自定义属性来关联同级的其他控件,它就好像一个标记,当父控件遇到标记时,就能为被标记的控件做一些特殊的事情,比如“为一组子控件添加背景”,而这些特殊的事情就定义在ConstraintHelper
的子类中。
自定义容器控件
我们不是正在寻找“如何把哪些子控件需要绘制小红点告诉父控件”的方法吗?借用ConstraintHelper
的思想方法就能实现。实现成功之后的布局文件应该长这样(伪码):
<TreasureBox
xmlns:android="http://schemas.android.com/apk/res/android">
<TextView
android:id="@+id/tv"/>
<Button
android:id="@+id/btn"/>
<ImageView
android:id="@+id/iv"/>
//'为tv,btn,iv绘制小红点'
<RedPointTreasure
app:reference_ids="tv,btn,iv"/>
</TreasureBox>
其中的TreasureBox
和RedPointTreasure
就是我们要实现的自定义容器控件和标记控件。
仿照ContraintLayout
写一个自定义容器控件:
class TreasureBox @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
ConstraintLayout(context, attrs, defStyleAttr) {
//'标记控件列表'
private var treasures = mutableListOf<Treasure>()
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<Int>()
//'在构造时解析自定义数据'
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”,定义如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="Treasure">
<attr name="reference_ids" format="string" />
</declare-styleable>
</resources>
绘制函数是抽象的,具体的绘制逻辑交给子类实现:
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<Float>
//'小红点圆心y偏移量'
private lateinit var offsetYs: MutableList<Float>
//'小红点半径'
private lateinit var radiuses: MutableList<Float>
//'小红点画笔'
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<View>(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 version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="RedPointTreasure">
<attr name="reference_radius" format="string" />
<attr name="reference_offsetX" format="string" />
<attr name="reference_offsetY" format="string" />
</declare-styleable>
</resources>
然后就可以在 xml 文件中完成小红点的绘制,效果图如下:
imagexml 定义如下:
<?xml version="1.0" encoding="utf-8"?>
<TreasureBox xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
android:text="Message"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/btn"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
android:text="Mail box"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/tv"
app:layout_constraintStart_toEndOf="@id/iv"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/iv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
android:src="@drawable/ic_voice_call"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/btn"
app:layout_constraintTop_toTopOf="parent" />
<RedPointTreasure
android:id="@+id/redPoint"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
//'分别为子控件tv,btn,iv绘制小红点'
app:reference_ids="tv,btn,iv"
//'tv,btn,iv小红点的半径分别是5,13,8'
app:reference_radius="5,13,8"
//'tv,btn,iv小红点的x偏移量分别是10,0,0'
app:reference_offsetY="10,0,0"
//'tv,btn,iv小红点的y偏移量分别是-10,0,0'
app:reference_offsetX="-10,0,0"
/>
</TreasureBox>
业务层通常需要动态改变小红点的显示状态,为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
完整的源码可以点击这里
推荐阅读
这也是读源码长知识系列的第三篇,该系列的特点是将源码中的设计思想运用到真实项目之中,系列文章目录如下:
网友评论