本文主要是讲解在 ViewGroup
中混合使用 addView()
和 xml布局文件
的方式来配置UI,自定义属性等入门知识请移步其他博客。
前言
因为项目需要决定自定义一个 ViewGroup
,但是在选定方案以后,全网搜索以后却找不到一个addView() + xml布局文件
混合添加View
的使用方式的讲解。能提到混合使用的文章就寥寥无几(实际上就看到一篇,被到处Ctrl CV
,但这篇文章对混合使用方式也只是说了句在研究),真正讲解如何混合使用的教程,我反正是没看到的。
无奈之下只能对着Google官方提供的与我需求相似度20%的 TextInputLayout
慢慢摸索,最后好歹算是搞出来了。想了想,一方面为了总结和记录;另一方面也是想补上这个缺口,使后来人不必再如我一般走弯路,特此写下此文,希望能有所作用。
需求
最近项目需要一个多种提示状态的输入框来替代现有的登录注册框,由于提示文本的位置、手机号涉及到国际区号的选择,加上考虑到后期布局可能变得更加复杂化,自定义 EditText
不能满足需求,只能决定采用自定义 ViewGroup
的方式来实现,由 ViewGroup
来对提示文本、输入框状态、状态指示器等进行控制。
需求样板
最终效果
方案选型
XML中使用自定义 ViewGroup 的方式
通过
addView()
方法将需要的控件添加到ViewGroup
中通过在
xml布局文件
中配置需要包含的控件混合布局:1+2
方案优缺点对比
方案1优点在于简洁,完全不需要另外配置
xml布局文件
,但也正是因此,它不具备在xml布局文件
中动态配置子View的扩展性-
方案2优点是具有完全的可配置性,可在
xml布局文件
中任意配置子View
,但缺点也很明显:通用的子
View
仍然需要在每个使用该ViewGroup
的xml布局文件
中配置,过于繁琐在
xml布局文件
中配置在ViewGroup
下的子View
都会被 add到ViewGroup
下,无法进一步分配
方案3的具有方案2的可配置性,同时又能通过方案1的方式来解决方案2的第1个缺点,也可以通过重写
ViewGroup
的addView()
方法来解决方案2的第2个缺点。
结论
由于前述的需求,我需要1个通用的 TextView
来作为提示文本,同时又需要一个 ViewGroup
来存放从xml布局文件中加载进来的子View
,这2个正对应方案2的2个问题,方案3正好能满足需求。
项目实施
在编写的过程中,有参考Google官方的 TextInputLayout
,这个控件很有意思,它结合 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很容易发现,但是在错误1的加持下,这个问题被掩盖了,我花了几个小时在错误的方法上面,最后一无所获。无奈之下改为使用
onLayout()
方法,最终从肉眼视觉上达到想要的效果。这个方案相比直接在加载View
的时候按需配置View树
显然会差一些,不论是感觉上还是性能上均如此。好消息是:次日我不甘心就这么算了,再次尝试
addView()
方法,终于给我发现了上述的2个坑,从而成功的使用addView()
方法做到了想要的效果。
这里说说TextInputLayout
给我的2个重要启发:
addView()
方法在控件从xml布局文件
转化为View
过程中发挥的作用setAddStatesFromChildren()
方法
我们先看 addView()
方法,setAddStatesFromChildren()
方法会在后面进行讲解。
addView() 方法
这里我重写的是带有3个参数的 addView(View child, int index, LayoutParams params)
,因为我需要用到第3个参数,各参数分别表示:
-
child
将要add进来的View
-
index
child
将被add到的position
,-1表示add到最后 -
params
将在child
上设置的LayoutParams
参数(其实就是包含child
在xml布局文件
中配置的属性的LayoutParams
)
关于 addView()
方法在UI创建过程中的作用大概看了一下源码和网上的解析文章,有了个粗略的了解。
简单来说就是:
在Android系统解析 xml布局文件 转换成View
的过程中,会调用当前正在解析的ViewGroup
中的addView()
方法,把 xml布局文件中该ViewGroup
包含的View
或ViewGroup
一个个的 add 进来。
接下来进入到代码解析阶段:
1. 先看看构造方法:
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监听代码
}
代码解析:
这部分是 ColorTextInputLayout
的构造方法,我在 ColorTextInputLayout
的构造方法中 new TextView
(提示文本 mTvHint
) 和 FrameLayout
(输入面板 mFlInputPanel
) 时分别给它们setTag()
,以便在 addView()
方法中把它们与xml布局文件
中的View
区分开来。同时 new ImageView
(状态指示器 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 {
// 输入面板当前已经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
已经在构造方法中add到mFlInputPanel
里了。
这个限定的作用是什么?
分析了项目可能的使用情况,最简单的使用情况就只包含一个EditText
,另外的情况就是包含多个控件。但是这里我并没有办法知到使用多个控件时到底想要什么样的摆放方式,甚至可能不同的地方需要不同的摆放方式,此外多个控件的摆放过于复杂,综合以上因素,我决定效仿ScrollView
:限定只允许包含1个child
。要么仅包含一个EditText
;要么包含一个包含EditText
的ViewGroup
,ViewGroup
内部的布局方式我不关心。
接着往下看,到
mFlInputPanel.addView(child, flp)
为止,这部分就是从子View
配置在xml布局文件
中的params
中取出marinXXX
属性,然后按照这些属性重新把子View
add到mFlInputPanel
中。再往下直到结束就是取
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添加此方法,否则输入面板设置的此方法不会生效
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
总数不变,EditText
越靠前,与它同级但比它靠后的View
就越多,这些View
不用再处理,理论上能提升一部分性能。当然,这是我这个递归方法不完美导致的。理论上最优方法就是从根节点一层一层的往叶子节点找,而不是找到一个 ViewGroup
就进入。算法学的不太好,这里后期再优化吧。
- 必须给
ViewGroup
添加此方法,否则构造方法中mFlInputPanel
设置的此方法不会生效。
项目实施 中我提到 TextInputLayout
给我 2个启发, setAddStatesFromChildren()
就是另一个,它的作用如下:
它将设置父View
与子View
的背景联动,实质就是在构建 ViewGroup
的 drawableState
时,会将子View
的所有 drawableState
合并在一起交给父View
,并在子View
刷新drawable
时通知父View
。
什么意思呢?就是子View
的 drawableState
发生变化时,ViewGroup
也会同步到此 drawableState
状态。
以我现在的需求来说,我需要在 EditText
获取到焦点时,将 mFlInputPanel
的背景图设置为获取焦点的状态图。按尝龟
啊不,常规做法就是:对 EditText
设置焦点监听事件,在焦点变化时更换 mFlInputPanel
背景图。但是这样一来很繁琐;另外如果 ColorTextInputLayout
外部也需要监听 EditText
焦点状态,二者就会冲突了。
而有了 setAddStatesFromChildren()
方法以后,一切都简单了:mFlInputPanel
首先设置一个 drawable
类型的 selector_xx.xml
背景,然后调用 setAddStatesFromChildren()
方法。这样,在 EditText
的 drawableState
状态(包括焦点状态)变化时,mFlInputPanel
将会收到通知,自动选择 selector_xx.xml
中对应状态的背景。
需要注意的是,
setDuplicateParentStateEnabled()
方法与setAddStatesFromChildren()
刚好相反,二者不可以一起使用,否则可能引起崩溃。
此外,经过我自己的使用发现,多层嵌套时,如果响应的ViewGroup
与 想监听的View
之间还有嵌套的ViewGroup
,那么需要在每一层ViewGroup
都调用setAddStatesFromChildren()
方法,响应的ViewGroup
才会生效。所以,在getEditTextFromViewGroup()
方法开头我也调用了setAddStatesFromChildren()
方法。
尾声
到这里,本次自定义 ViewGroup
的 xml布局文件 + addView()
混合添加View的使用就讲完了,感谢阅读,有什么不对的地方也请指正。