此文標題想了很久久久,本起名爲《讀原碼長知識 | 小紅點的一種實現》,但糾結了下,以爲仍是應該隸屬於自定義控件系列~~android
上篇介紹了兩種實現小紅點的方案,分別是多控件疊加和單控件繪製,其中第二個方案有一個缺點:類型綁定。致使它沒法被不一樣類型控件所複用。這篇從父控件的角度出發,提出一個新的方案:容器控件繪製,以突破類型綁定。git
這是自定義控件系列教程的第六篇,系列文章目錄以下:github
本文使用 Kotlin 編寫,相關係列教程能夠點擊這裏canvas
假設這樣一個場景:一個容器控件中,有三種不一樣類型的控件須要在右上角顯示小紅點。若使用上一篇中的「單控件繪製方案」,就必須自定義三種不一樣類型的控件,在其矩形區域的右上角繪製小紅點。數組
可不能夠把繪製工做交給容器控件?bash
容器控件能垂手可得地知道子控件矩形區域的座標,有什麼辦法把「哪些孩子須要繪製小紅點」告訴容器控件,以讓其在相應位置繪製?app
在讀androidx.constraintlayout.helper.widget.Layer
源碼時,發現它用一種巧妙的方式將子控件的信息告訴容器控件。dom
Layer
是一個配合ConstraintLayout
使用的控件,可實現以下效果:ide
<?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 文件中完成小紅點的繪製,效果圖以下:
xml 定義以下:<?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)
複製代碼
這套容器控件+標記控件的組合除了能夠繪製小紅點,還能夠作其餘不少事情。這是一套子控件和父控件相互通訊的方式。
完整的源碼能夠點擊這裏