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

本文主要是讲解在 ViewGroup 中混合使用 addView()xml布局文件 的方式来配置UI,自定义属性等入门知识请移步其他博客。

前言

因为项目需要决定自定义一个 ViewGroup,但是在选定方案以后,全网搜索以后却找不到一个addView() + xml布局文件 混合添加View的使用方式的讲解。能提到混合使用的文章就寥寥无几(实际上就看到一篇,被到处Ctrl CV,但这篇文章对混合使用方式也只是说了句在研究),真正讲解如何混合使用的教程,我反正是没看到的。

无奈之下只能对着Google官方提供的与我需求相似度20%的 TextInputLayout 慢慢摸索,最后好歹算是搞出来了。想了想,一方面为了总结和记录;另一方面也是想补上这个缺口,使后来人不必再如我一般走弯路,特此写下此文,希望能有所作用。


需求

最近项目需要一个多种提示状态的输入框来替代现有的登录注册框,由于提示文本的位置、手机号涉及到国际区号的选择,加上考虑到后期布局可能变得更加复杂化,自定义 EditText 不能满足需求,只能决定采用自定义 ViewGroup 的方式来实现,由 ViewGroup 来对提示文本、输入框状态、状态指示器等进行控制。

需求样板

xuqiu.jpg

最终效果

效果图.gif


方案选型

XML中使用自定义 ViewGroup 的方式

  1. 通过addView()方法将需要的控件添加到ViewGroup

  2. 通过在xml布局文件中配置需要包含的控件

  3. 混合布局:1+2

方案优缺点对比

  • 方案1优点在于简洁,完全不需要另外配置xml布局文件,但也正是因此,它不具备在xml布局文件中动态配置子View的扩展性

  • 方案2优点是具有完全的可配置性,可在xml布局文件中任意配置子View,但缺点也很明显:

    1. 通用的子 View仍然需要在每个使用该 ViewGroupxml布局文件 中配置,过于繁琐

    2. xml布局文件中配置在 ViewGroup下的子 View都会被 add到 ViewGroup下,无法进一步分配

  • 方案3的具有方案2的可配置性,同时又能通过方案1的方式来解决方案2的第1个缺点,也可以通过重写 ViewGroupaddView() 方法来解决方案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个坑里:

  1. addView(View child, int index, LayoutParams params)方法可能是因为编译后的缘故,Override标志没了,我误以为此方法并非重写的方法。
  2. 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 包含的 ViewViewGroup 一个个的 add 进来。


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

show_code.jpg


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 中。最后,把 mTvHintmFlInputPanel 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;要么包含一个包含 EditTextViewGroupViewGroup 内部的布局方式我不关心。

  • 接着往下看,到 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的背景联动,实质就是在构建 ViewGroupdrawableState 时,会将子View的所有 drawableState 合并在一起交给父View,并在子View刷新drawable时通知父View

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

lizi.jpg

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

changgui.jpg

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

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

需要注意的是,setDuplicateParentStateEnabled() 方法与 setAddStatesFromChildren() 刚好相反,二者不可以一起使用,否则可能引起崩溃。
此外,经过我自己的使用发现,多层嵌套时,如果响应的 ViewGroup 与 想监听的 View 之间还有嵌套的 ViewGroup ,那么需要在每一层 ViewGroup 都调用 setAddStatesFromChildren() 方法,响应的 ViewGroup 才会生效。所以,在getEditTextFromViewGroup() 方法开头我也调用了 setAddStatesFromChildren() 方法。


尾声

到这里,本次自定义 ViewGroupxml布局文件 + addView() 混合添加View的使用就讲完了,感谢阅读,有什么不对的地方也请指正。

你可能感兴趣的:(Android 自定义多状态提示输入布局 ColorTextInputLayout)