Android 自定义多状态提示输入布局 ColorTextInputLayout

这里其实我走了一段弯路,开始参考TextInputLayout源码时,陷入它的2个坑里:

  1. addView(View child, int index, LayoutParams params)方法可能是因为编译后的缘故,Override标志没了,我误以为此方法并非重写的方法。
  1. params 没有标注完整的具体类型,因为TextInputLayout本身是继承的LinearLayout,我想当然的把它当作了LinearLayout.LayoutParams,而实际上它始终都是ViewGroup.LayoutParams

本来错误2很容易发现,但是在错误1的加持下,这个问题被掩盖了,我花了几个小时在错误的方法上面,最后一无所获。无奈之下改为使用onLayout()方法,最终从肉眼视觉上达到想要的效果。这个方案相比直接在加载View的时候按需配置View树显然会差一些,不论是感觉上还是性能上均如此。

好消息是:次日我不甘心就这么算了,再次尝试addView() 方法,终于给我发现了上述的2个坑,从而成功的使用addView() 方法做到了想要的效果。

这里说说TextInputLayout 给我的2个重要启发:

  1. addView() 方法在控件从xml布局文件转化为View过程中发挥的作用

  2. 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参数(其实就是包含childxml布局文件中配置的属性的LayoutParams

关于 addView() 方法在UI创建过程中的作用大概看了一下源码和网上的解析文章,有了个粗略的了解。

简单来说就是: 在Android系统解析 xml布局文件 转换成 View 的过程中,会调用当前正在解析的 ViewGroup 中的 addView() 方法,把 xml布局文件中该 ViewGroup 包含的 View 或 ViewGroup 一个个的 add 进来。

接下来进入到代码解析阶段:

[图片上传失败…(image-9eb6a8-1628150391084)]

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 的 ViewGroupViewGroup 内部的布局方式我不关心。

  • 接着往下看,到 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 就进入。算法学的不太好,这里后期再优化吧。

  1. 必须给 ViewGroup 添加此方法,否则构造方法中 mFlInputPanel 设置的此方法不会生效。

项目实施 中我提到 TextInputLayout 给我 2个启发, setAddStatesFromChildren() 就是另一个,它的作用如下: 它将设置父View与子View的背景联动,实质就是在构建 ViewGroup 的 drawableState 时,会将子View的所有 drawableState 合并在一起交给父View,并在子View刷新drawable时通知父View

什么意思呢?就是子View的 drawableState 发生变化时,ViewGroup 也会同步到此 drawableState 状态。

[图片上传失败…(image-588c35-1628150391082)]

以我现在的需求来说,我需要在 EditText 获取到焦点时,将 mFlInputPanel 的背景图设置为获取焦点的状态图。按尝龟

[图片上传失败…(image-8dbc86-1628150391082)]

啊不,常规做法就是:对 EditText 设置焦点监听事件,在焦点变化时更换 mFlInputPanel 背景图。但是这样一来很繁琐;另外如果 ColorTextInputLayout 外部也需要监听 EditText 焦点状态,二者就会冲突了。

而有了 setAddStatesFromChildren() 方法以后,一切都简单了:mFlInputPanel 首先设置一个 drawable 类型的 selector_xx.xml 背景,然后调用 setAddStatesFromChildren() 方法。这样,在 EditText 的 drawableState 状态(包括焦点状态)变化时,mFlInputPanel 将会收到通知,自动选择 selector_xx.xml 中对应状态的背景。

需要注意的是,setDuplicateParentStateEnabled() 方法与 setAddStatesFromChildren() 刚好相反,二者不可以一起使用,否则可能引起崩溃。 此外,经过我自己的使用发现,多层嵌套时,如果响应的 ViewGroup 与 想监听的 View 之间还有嵌套的 ViewGroup ,那么需要在每一层 ViewGroup 都调用 setDuplicateParentStateEnabled() 方法,响应的 ViewGroup 才会生效。所以,在 getEditTextFromViewGroup() 方法开头我也调用了 setDuplicateParentStateEnabled() 方法。
尾声
==
到这里,本次自定义 ViewGroup 的 xml布局文件 + addView() 混合添加View的使用就讲完了,感谢阅读,有什么不对的地方也请指正。如有转载需要,请标明出处,谢谢。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值