本文主要是講解在 ViewGroup
中混合使用 addView()
和 xml布局文件
的方式來配置UI,自定義屬性等入門知識請移步其他博客。
前言
因為項目需要決定自定義一個 ViewGroup
,但是在選定方案以后,全網(wǎng)搜索以后卻找不到一個addView() + xml布局文件
混合添加View
的使用方式的講解。能提到混合使用的文章就寥寥無幾(實際上就看到一篇,被到處Ctrl CV
,但這篇文章對混合使用方式也只是說了句在研究),真正講解如何混合使用的教程,我反正是沒看到的。
無奈之下只能對著Google官方提供的與我需求相似度20%的 TextInputLayout
慢慢摸索,最后好歹算是搞出來了。想了想,一方面為了總結(jié)和記錄;另一方面也是想補上這個缺口,使后來人不必再如我一般走彎路,特此寫下此文,希望能有所作用。
需求
最近項目需要一個多種提示狀態(tài)的輸入框來替代現(xiàn)有的登錄注冊框,由于提示文本的位置、手機號涉及到國際區(qū)號的選擇,加上考慮到后期布局可能變得更加復雜化,自定義 EditText
不能滿足需求,只能決定采用自定義 ViewGroup
的方式來實現(xiàn),由 ViewGroup
來對提示文本、輸入框狀態(tài)、狀態(tài)指示器等進行控制。
需求樣板
最終效果
方案選型
XML中使用自定義 ViewGroup 的方式
通過
addView()
方法將需要的控件添加到ViewGroup
中通過在
xml布局文件
中配置需要包含的控件混合布局:1+2
方案優(yōu)缺點對比
方案1優(yōu)點在于簡潔,完全不需要另外配置
xml布局文件
,但也正是因此,它不具備在xml布局文件
中動態(tài)配置子View的擴展性-
方案2優(yōu)點是具有完全的可配置性,可在
xml布局文件
中任意配置子View
,但缺點也很明顯:通用的子
View
仍然需要在每個使用該ViewGroup
的xml布局文件
中配置,過于繁瑣在
xml布局文件
中配置在ViewGroup
下的子View
都會被 add到ViewGroup
下,無法進一步分配
方案3的具有方案2的可配置性,同時又能通過方案1的方式來解決方案2的第1個缺點,也可以通過重寫
ViewGroup
的addView()
方法來解決方案2的第2個缺點。
結(jié)論
由于前述的需求,我需要1個通用的 TextView
來作為提示文本,同時又需要一個 ViewGroup
來存放從xml布局文件中加載進來的子View
,這2個正對應(yīng)方案2的2個問題,方案3正好能滿足需求。
項目實施
在編寫的過程中,有參考Google官方的 TextInputLayout
,這個控件很有意思,它結(jié)合 EditText
一起使用的時候可以對hint
文本進行移動,具體情況想了解可以搜一下看看。
TextInputLayout
重寫的addView()
方法:
public void addView(View child, int index, LayoutParams params) {
if (child instanceof EditText) {
FrameLayout.LayoutParams flp = new FrameLayout.LayoutParams(params);
flp.gravity = 16 | flp.gravity & -113;
this.inputFrame.addView(child, flp);
this.inputFrame.setLayoutParams(params);
this.updateInputLayoutMargins();
this.setEditText((EditText)child);
} else {
super.addView(child, index, params);
}
}
這里其實我走了一段彎路,開始參考
TextInputLayout
源碼時,陷入它的2個坑里:
addView(View child, int index, LayoutParams params)
方法可能是因為編譯后的緣故,Override
標志沒了,我誤以為此方法并非重寫的方法。params
沒有標注完整的具體類型,因為TextInputLayout
本身是繼承的LinearLayout
,我想當然的把它當作了LinearLayout.LayoutParams
,而實際上它始終都是ViewGroup.LayoutParams
。本來錯誤2很容易發(fā)現(xiàn),但是在錯誤1的加持下,這個問題被掩蓋了,我花了幾個小時在錯誤的方法上面,最后一無所獲。無奈之下改為使用
onLayout()
方法,最終從肉眼視覺上達到想要的效果。這個方案相比直接在加載View
的時候按需配置View樹
顯然會差一些,不論是感覺上還是性能上均如此。好消息是:次日我不甘心就這么算了,再次嘗試
addView()
方法,終于給我發(fā)現(xiàn)了上述的2個坑,從而成功的使用addView()
方法做到了想要的效果。
這里說說TextInputLayout
給我的2個重要啟發(fā):
addView()
方法在控件從xml布局文件
轉(zhuǎn)化為View
過程中發(fā)揮的作用setAddStatesFromChildren()
方法
我們先看 addView()
方法,setAddStatesFromChildren()
方法會在后面進行講解。
addView() 方法
這里我重寫的是帶有3個參數(shù)的 addView(View child, int index, LayoutParams params)
,因為我需要用到第3個參數(shù),各參數(shù)分別表示:
-
child
????? 將要add進來的View
-
index
????child
將被add到的position
,-1表示add到最后 -
params
?? 將在child
上設(shè)置的LayoutParams
參數(shù)(其實就是包含child
在xml布局文件
中配置的屬性的LayoutParams
)
關(guān)于 addView()
方法在UI創(chuàng)建過程中的作用大概看了一下源碼和網(wǎng)上的解析文章,有了個粗略的了解。
簡單來說就是:
在Android系統(tǒng)解析 xml布局文件 轉(zhuǎn)換成View
的過程中,會調(diào)用當前正在解析的ViewGroup
中的addView()
方法,把 xml布局文件中該ViewGroup
包含的View
或ViewGroup
一個個的 add 進來。
接下來進入到代碼解析階段:
1. 先看看構(gòu)造方法:
public ColorTextInputLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr, 0);
setOrientation(VERTICAL);
setWillNotDraw(true);
mTvHint = new TextView(context);
mTvHint.setTag(TAG_HINT);
mFlInputPanel = new FrameLayout(context);
mFlInputPanel.setTag(TAG_PANEL);
mFlInputPanel.setAddStatesFromChildren(true);
mFlInputPanel.setBackgroundResource(R.drawable.selector_color_hint_panel);
mIvIndicator = new ImageView(context);
FrameLayout.LayoutParams indicatorLp = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.MATCH_PARENT);
mFlInputPanel.addView(mIvIndicator, 0, indicatorLp);
LinearLayout.LayoutParams rootLp = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
addView(mTvHint, 0, rootLp);
addView(mFlInputPanel, 1, rootLp);
...... 省略掉了OnGlobalFocusChangeListener監(jiān)聽代碼
}
代碼解析:
這部分是 ColorTextInputLayout
的構(gòu)造方法,我在 ColorTextInputLayout
的構(gòu)造方法中 new TextView
(提示文本 mTvHint
) 和 FrameLayout
(輸入面板 mFlInputPanel
) 時分別給它們setTag()
,以便在 addView()
方法中把它們與xml布局文件
中的View
區(qū)分開來。同時 new ImageView
(狀態(tài)指示器 mIvIndicator
),并add到 mFlInputPanel
中。最后,把 mTvHint
和 mFlInputPanel
add到 ColorTextInputLayout
中。
這里需要注意 mFlInputPanel.setAddStatesFromChildren(true)
這一行,這里我先不說它的作用,留到后面另一個使用到的地方再一起說。
2. 再來看看重寫的 addView()
方法
@Override
public void addView(View child, int index, ViewGroup.LayoutParams params) {
String tag = (String) child.getTag();
// 提示和輸入面板add到本ViewGroup,其他的add到容器中
if (TextUtils.equals(tag, TAG_HINT) || TextUtils.equals(tag, TAG_PANEL)) {
super.addView(child, index, params);
} else {
// 輸入面板當前已經(jīng)add了圖標指示器,最多只能再add 1個子控件
if (mFlInputPanel.getChildCount() > 1) {
throw new IllegalStateException("ColorTextInputLayout can host only one child");
}
FrameLayout.LayoutParams flp = new FrameLayout.LayoutParams(params);
MarginLayoutParams marginLp = (MarginLayoutParams) params;
flp.leftMargin = marginLp.leftMargin == 0 ? marginLp.getMarginStart() : marginLp.leftMargin;
flp.rightMargin = marginLp.rightMargin == 0 ? marginLp.getMarginEnd() : marginLp.rightMargin;
flp.topMargin = marginLp.topMargin;
flp.bottomMargin = marginLp.bottomMargin;
mFlInputPanel.addView(child, flp);
if (child instanceof EditText) {
setEditText((EditText) child);
} else if (child instanceof ViewGroup) {
EditText edt = getEditTextFromViewGroup((ViewGroup) child);
if (edt == null) {
throw new IllegalStateException("The ViewGroup in ColorTextInputLayout must have one EditText");
}
setEditText(edt);
} else {
throw new IllegalStateException("ColorTextInputLayout can host only an EditText or a ViewGroup containing EditText");
}
}
}
代碼解析:
if 分支表示這2個控件依然按照 ColorTextInputLayout
的父類的 addView()
方法添加到 ColorTextInputLayout
中;else 分支不用我說你們也都能猜到了,是的,這就是把 xml布局文件
中包裹在 ColorTextInputLayout
下的子View
添加到輸入面板(mFlInputPanel
)的代碼,這里說一下:
-
if (mFlInputPanel.getChildCount() > 1)
是用來限定xml布局文件
中只能包含一個child
,類似ScrollView
。為什么是大于1而不是大于0呢?因為mIvIndicator
已經(jīng)在構(gòu)造方法中add到mFlInputPanel
里了。
這個限定的作用是什么?
分析了項目可能的使用情況,最簡單的使用情況就只包含一個EditText
,另外的情況就是包含多個控件。但是這里我并沒有辦法知到使用多個控件時到底想要什么樣的擺放方式,甚至可能不同的地方需要不同的擺放方式,此外多個控件的擺放過于復雜,綜合以上因素,我決定效仿ScrollView
:限定只允許包含1個child
。要么僅包含一個EditText
;要么包含一個包含EditText
的ViewGroup
,ViewGroup
內(nèi)部的布局方式我不關(guān)心。
接著往下看,到
mFlInputPanel.addView(child, flp)
為止,這部分就是從子View
配置在xml布局文件
中的params
中取出marinXXX
屬性,然后按照這些屬性重新把子View
add到mFlInputPanel
中。再往下直到結(jié)束就是取
EditText
的過程了:若xml布局文件中,ColorTextInputLayout
當前包裹的是EditText
則直接setEditText()
;若包裹的是ViewGroup
,先通過getEditTextFromViewGroup()
方法取出ViewGroup
中的EditText
,再setEditText()
;如果以上2種條件都不滿足,則拋出異常,提示xml布局文件
中包裹的子View
類型錯誤。setEditText()
就是一個將View
賦值給全局變量mEditText
的方法,這里不多說。
3. 下面看看getEditTextFromViewGroup() 方法
/**
* 使用遞歸來遍歷View樹,從ViewGroup中取出EditText(強烈建議只包含一個EditText)
*
* @param viewGroup
* @return 從ViewGroup中取出的EditText,若ViewGroup包含多個EditText,將始終只返回取到的第一個;
*/
private EditText getEditTextFromViewGroup(ViewGroup viewGroup) {
// 必須給ViewGroup添加此方法,否則輸入面板設(shè)置的此方法不會生效
viewGroup.setAddStatesFromChildren(true);
EditText editText = null;
int childCount = viewGroup.getChildCount();
for (int i = 0; i < childCount; i++) {
View child = viewGroup.getChildAt(i);
// EditText越靠前,在View樹中同級靠后的就不用處理
if (!(child instanceof ViewGroup)) {
if (child instanceof EditText) {
editText = (EditText) child;
break;
}
} else {
editText = getEditTextFromViewGroup((ViewGroup) child);
}
}
return editText;
}
代碼解析:
總體上是通過遞歸的方式從 ViewGroup
中取出 EditText
。這里有2個點要提一下:
1.在
ViewGroup
中,EditText
盡可能的放到前面
在多層 ViewGroup
嵌套的情況下,假定View樹
上同級的View
總數(shù)不變,EditText
越靠前,與它同級但比它靠后的View
就越多,這些View
不用再處理,理論上能提升一部分性能。當然,這是我這個遞歸方法不完美導致的。理論上最優(yōu)方法就是從根節(jié)點一層一層的往葉子節(jié)點找,而不是找到一個 ViewGroup
就進入。算法學的不太好,這里后期再優(yōu)化吧。
- 必須給
ViewGroup
添加此方法,否則構(gòu)造方法中mFlInputPanel
設(shè)置的此方法不會生效。
項目實施 中我提到 TextInputLayout
給我 2個啟發(fā), setAddStatesFromChildren()
就是另一個,它的作用如下:
它將設(shè)置父View
與子View
的背景聯(lián)動,實質(zhì)就是在構(gòu)建 ViewGroup
的 drawableState
時,會將子View
的所有 drawableState
合并在一起交給父View
,并在子View
刷新drawable
時通知父View
。
什么意思呢?就是子View
的 drawableState
發(fā)生變化時,ViewGroup
也會同步到此 drawableState
狀態(tài)。
以我現(xiàn)在的需求來說,我需要在 EditText
獲取到焦點時,將 mFlInputPanel
的背景圖設(shè)置為獲取焦點的狀態(tài)圖。按嘗龜
啊不,常規(guī)做法就是:對 EditText
設(shè)置焦點監(jiān)聽事件,在焦點變化時更換 mFlInputPanel
背景圖。但是這樣一來很繁瑣;另外如果 ColorTextInputLayout
外部也需要監(jiān)聽 EditText
焦點狀態(tài),二者就會沖突了。
而有了 setAddStatesFromChildren()
方法以后,一切都簡單了:mFlInputPanel
首先設(shè)置一個 drawable
類型的 selector_xx.xml
背景,然后調(diào)用 setAddStatesFromChildren()
方法。這樣,在 EditText
的 drawableState
狀態(tài)(包括焦點狀態(tài))變化時,mFlInputPanel
將會收到通知,自動選擇 selector_xx.xml
中對應(yīng)狀態(tài)的背景。
需要注意的是,
setDuplicateParentStateEnabled()
方法與setAddStatesFromChildren()
剛好相反,二者不可以一起使用,否則可能引起崩潰。
此外,經(jīng)過我自己的使用發(fā)現(xiàn),多層嵌套時,如果響應(yīng)的ViewGroup
與 想監(jiān)聽的View
之間還有嵌套的ViewGroup
,那么需要在每一層ViewGroup
都調(diào)用setAddStatesFromChildren()
方法,響應(yīng)的ViewGroup
才會生效。所以,在getEditTextFromViewGroup()
方法開頭我也調(diào)用了setAddStatesFromChildren()
方法。
尾聲
到這里,本次自定義 ViewGroup
的 xml布局文件 + addView()
混合添加View的使用就講完了,感謝閱讀,有什么不對的地方也請指正。