做 Android 输入框,绕不开 TextInputLayout、TextInputEditText、AppCompatEditText 这三兄弟。我见过太多人把它们混在一起用,结果要么布局写出来 hint 叠加成两行,要么主题一换直接 IllegalArgumentException 崩溃,要么代码里对着 TextInputLayout 调 setText 半天找不到方法。这篇文章从控件定位、环境配置、交互实现到常见坑一次性说透,你可以直接照着抄。
TextInputLayout 是 Material Components 里负责“输入框表现层”的容器,TextInputEditText 是官方推荐放在它里面的子输入框,AppCompatEditText 则是 AndroidX 兼容库里的通用 EditText 基类。三者不是随便组合,选错会直接影响浮动标签、错误提示、密码切换这些功能。这篇内容适合刚接触 Material 控件的开发者,也适合已经用了但经常踩坑的人,我会把“为什么这么做”也一并讲清楚。
1. 控件定位与选型思路
1.1 TextInputLayout 是容器,不是输入框
很多新人以为 TextInputLayout 是一个自带边框、自带浮动提示的“新输入框”,悲伤的是,它本质上只是一个 ViewGroup。它继承自 LinearLayout,真正的输入能力全靠内部塞进去的那个 EditText 提供,自己并不会处理软键盘,也不持有输入文本的完整逻辑。
它负责的是“外部表现”:hint 在获得焦点或者输入内容后向上浮起,错误文案在输入框下方带动画出现,密码框右侧显示眼睛图标,底部显示字符计数器,还可以加前缀、后缀图标。明白这一点非常重要,因为它意味着你不能把 TextInputLayout 当成一个独立的 EditText 去用,比如给它直接调 setText,没有这个 API,得先通过 getEditText() 得到内部输入控件。
1.2 TextInputEditText 与 AppCompatEditText 的真正区别
TextInputEditText 实际上继承自 AppCompatEditText,所以它的身份首先是一个兼容版 EditText。AppCompatEditText 做的事情是在 AndroidX AppCompat 的框架下,统一处理 backgroundTint、文本颜色、光标样式这些在不同系统版本上的表现差异,让同一个输入框在 Android 5.0 和 Android 14 上看起来尽量一致。
TextInputEditText 在继承 AppCompatEditText 的基础之上,又多了一个专门为 TextInputLayout 服务的逻辑:当它被放进 TextInputLayout 里面时,它会把自身的 hint 逻辑委托给外层容器。说人话就是,你在 TextInputEditText 上调用 setHint,实际生效的是外边 TextInputLayout 的浮动标签。这样就不会出现容器一个 hint、输入框自己又是一个 hint,导致两个文本同时显示的混乱局面。
1.3 什么时候用哪套
直接看需求场景就清楚了:
- 需要浮动标签、错误提示、密码切换、计数器、前后缀图标:用 TextInputLayout + TextInputEditText。
- 只是普通输入框,不需要容器化样式:用 AppCompatEditText 或者普通 EditText 都行,但为了主题统一,建议项目里优先用 AppCompatEditText。
- 需要下拉联想、自动补全:TextInputLayout 里面放 AutoCompleteTextView,和放 TextInputEditText 的思路一样。
- 老项目迁移:哪怕只是想把输入框换个圆角边框,也建议整套换成 TextInputLayout,不要试图在普通 EditText 上叠加 shape。
选型原则就一句话:外层一旦用了 TextInputLayout,内层就用 TextInputEditText,别混搭。
2. 环境与基础布局
2.1 依赖和主题配置
使用 TextInputLayout 需要引入 Material Components 库,AppCompatEditText 则来自 AndroidX AppCompat。建议在 build.gradle 里这样写:
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0'版本号可以根据项目实际情况调整,但最好保证 appcompat 和 material 都是比较新的稳定版本。引入依赖只是第一步,更重要的是全局主题。如果项目主题还停留在 Theme.AppCompat,在布局里直接使用 TextInputLayout,运行时大概率会看到这样的异常:
Caused by: java.lang.IllegalArgumentException: The style on this component requires your app theme to be Theme.MaterialComponents (or a descendant).
解决办法是把主题改为 Material 系:
<style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar"> <!-- 自定义颜色等属性 --> </style>如果项目不想动全局主题,也可以在现有主题上挂一个 overlay,或者在根布局上单独设置 android:theme。但以我个人的经验,最省心的还是全局改主题,因为 Material 库的其他控件,如 FloatingActionButton、BottomAppBar,都对主题有同样的要求。
2.2 最小可运行代码
布局文件里最基础的写法长这样:
<com.google.android.material.textfield.TextInputLayout android:id="@+id/tilUsername" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="用户名"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/etUsername" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="text" /> </com.google.android.material.textfield.TextInputLayout>注意一个容易犯的错:不要在 TextInputEditText 上再写 android:hint。既然你已经把 hint 放在了 TextInputLayout 上,内层再写就是重复标签。实际运行的时候内层 hint 会被委托给外层,但很多人在 XML 里习惯顺手写上,结果自己也分不清到底哪个生效。约定统一放在外层,规则就清楚很多。
外层 TextInputLayout 的 layout_height 建议设置 wrap_content,让它随内部输入控件的高度自动撑开,避免固定高度导致浮动标签被裁剪或者错误提示显示不全。
2.3 常用属性速查
这里按照我平时写布局的习惯,把常用属性整理成一个速查表:
| 属性 | 作用 | 备注 |
|---|---|---|
| app:boxBackgroundMode | 输入框背景模式,可选 filled、outline、none | 老项目换框样式最常用 |
| app:boxCornerRadius | 输入框圆角大小 | 只对 filled 和 outline 生效 |
| app:boxStrokeColor | outline 模式下边框颜色 | 可以用 selector 实现焦点变色 |
| app:boxStrokeWidth | outline 模式下边框宽度 | 默认通常够用,需要加粗时调整 |
| app:boxBackgroundColor | filled 模式背景色 | 不要直接用硬编码色值 |
| app:hintEnabled | 是否启用浮动标签 | 置 false 等价于普通静态 hint |
| app:hintAnimationEnabled | 浮动标签是否带动画 | 追求极简时可关掉 |
| app:errorEnabled | 是否开启错误信息 | 建议常驻 true,避免布局跳动 |
| app:errorIconDrawable | 错误时的图标 | 可以自定义为圆圈感叹号 |
| app:helperText | 辅助说明文字 | 适合放格式要求 |
| app:counterEnabled | 是否显示字符计数 | 常配合 maxLength 使用 |
| app:counterMaxLength | 计数上限 | 和输入框 android:maxLength 保持一致 |
| app:endIconMode | 尾部图标模式 | password_toggle 是内置密码切换 |
| app:startIconDrawable | 起始图标 | 设置后自动显示在输入框最左 |
| app:prefixText | 前缀文本 | 比如金额单位 ¥ |
| app:suffixText | 后缀文本 | 比如 @qq.com |
| app:placeholderText | 占位提示 | 输入内容后消失 |
这些属性看上去多,但不用全部记住。我用的最多的其实就是 boxBackgroundMode、errorEnabled、endIconMode、counterEnabled 几个。其它属性等需要时再查表也来得及。
3. 常用交互实操
3.1 错误提示的正确用法
错误提示是 TextInputLayout 最容易诱发布局事故的功能。它在输入框下方会有一个独立的 TextView 区域,默认尺寸很小,如果没有内容就不占空间,一旦调用 setError 就会突然撑开,导致下方按钮整体下跳。
建议在 XML 里把 errorEnabled 设为 true,让错误区域从一开始就预留位置,然后代码里通过 setError 控制内容:
tilUsername.error = null tilUsername.error = "请输入用户名"当 error 为 null 时,错误区域在视觉上会清空,但 errorEnabled 仍然是 true,所以高度不会跳动。这一点在表单提交类页面非常重要。用户体验上,比起突然往下弹一个错误信息,不如从一开始就把这块空间稳定占住,视觉上更可控。
如果你希望错误出现时输入框有红色描边,靠的是输入框自身在 error 状态下的 strokeColor 变化,不需要自己额外改颜色选择器。Material 组件已经处理了。
3.2 输入时清除错误状态
用户点击提交后显示“请输入用户名”,等用户真正开始打字时,大多数 App 都会立刻把错误信息清掉。最直接的方式是在 TextInputEditText 上加 TextWatcher:
etUsername.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { if (s?.isNotEmpty() == true) { tilUsername.error = null } } override fun afterTextChanged(s: Editable?) {} })这里有一个小细节:TextWatcher 的清除逻辑不要放在 afterTextChanged 里反复调用 setError(null),只在文本有变化时清一次就好。实际上,onTextChanged 里判断 s.isNotEmpty() 为空不触发清除,能避免用户把内容全删光时错误状态被提前藏掉。
3.3 密码可见性切换
老版本 Material 库提供的密码切换属性是 app:passwordToggleEnabled="true",现在已经废弃,推荐使用 endIconMode。在布局里写:
<com.google.android.material.textfield.TextInputLayout android:id="@+id/tilPassword" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="密码" app:endIconMode="password_toggle"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/etPassword" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="textPassword" /> </com.google.android.material.textfield.TextInputLayout>TextInputLayout 会根据子输入框的 inputType 自动切换眼睛图标状态。当输入框当前是 textPassword 时显示“明文”图标,点击后变成普通 text,图标变为“隐藏”样式。这里你其实不需要关心内部光标位置,Material 已经处理得比较完善,用户点击切换不会丢焦点。
如果项目里想自定义眼睛图标,可以设置 app:endIconDrawable 指向一个 selector,里面用状态不同区分明文和密文。图标需要遵循 Material 的图标规范,尺寸建议 24dp。
3.4 辅助文本、字符计数与前缀后缀
辅助文本适合放一些“密码必须包含大小写”之类的格式说明。和错误提示叠加时,错误信息的优先级更高,会自动覆盖辅助文本,错误消失后辅助文本会恢复显示。布局里可以这样:
app:helperText="8-16位字母或数字" app:counterEnabled="true" app:counterMaxLength="16"counterEnabled 开启后,TextInputLayout 右下角会显示“0/16”这样的计数。需要注意的是,counterMaxLength 只是计数上限,真正限制输入长度还是要靠 TextInputEditText 上的 android:maxLength,否则用户输入超过 16 位后,计数会变红但内容仍然会继续输入。
前缀后缀则是我很喜欢的功能,做手机号填空时可以写:
app:prefixText="+86" app:suffixText="@qq.com"前缀和后缀的文本会永远显示在输入框内,不会随着输入内容滚动消失,这对表达固定单位非常方便。
3.5 圆角与描边样式
老项目里常见的做法是给 EditText 设一个自定义 shape background,做圆角边框。用 TextInputLayout 之后可以直接用 boxBackgroundMode,省掉 shape。
想要圆角外框风格:
app:boxBackgroundMode="outline" app:boxCornerRadius="8dp"想要仿 iOS 白色填充风格:
app:boxBackgroundMode="filled" app:boxBackgroundColor="#F5F5F5"焦点变化时边框变色,需要把 boxStrokeColor 改成 selector:
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="#2196F3" android:state_focused="true"/> <item android:color="#BDBDBD"/> </selector>这样就不需要在代码里监听焦点事件去改背景了。
4. 踩坑记录与排查方法
4.1 hint 重叠的问题
这个坑我碰过太多次了。现象是 TextInputLayout 的浮动标签和输入框内文字同时出现,叠在一起非常丑,或者输入框内永远显示着两个不同的提示。
原因几乎都是内层用了 AppCompatEditText 而不是 TextInputEditText。AppCompatEditText 不会把 hint 委托给外层,所以当外层 TextInputLayout 设置了 android:hint,内层自己也设置了 android:hint 时,内外两层都会绘制各自的提示文字,于是视觉上就重叠了。有人会用不设置内层 hint 的办法暂时躲过这个问题,但只要后续别人在布局上补了一个内层 hint,就会旧病复发。
根治方案:使用 TextInputEditText,并且把 hint 统一写在外层 TextInputLayout 上。
4.2 setError(null) 后错误提示不消失
有时候代码里明明把 error 清空了,输入框下方却还留着一条空白,或者红色边框久久不消失。这种情况大多是 errorEnabled 的开关时机不对。
正确逻辑是:
til.error = null而不是:
til.isErrorEnabled = false前者只是隐藏当前错误内容,后者会把整个错误区域直接移出布局。如果后续再次调用 setError,因为 errorEnabled 已经被关闭,错误也不会显示。所以想保留错误能力但暂时清空内容,用 setError(null) 就好。如果确实想彻底关闭错误显示,再关 isErrorEnabled,而且最好同时加上 requestLayout 让布局重新计算。
还有一点,setError 之后不要立刻再对输入框调用 requestFocus,后者有时候会让错误状态被系统重置,具体表现因版本而异。
4.3 主题崩溃
主题崩溃的场景主要在两种情况下发生:新项目直接用了 Material 控件,但主主题还是 Theme.AppCompat;或者项目原本是 Theme.MaterialComponents,升级到 Material 1.10 后新增了 Material3 风格控件,导致样式冲突。
官方给的限制是:TextInputLayout 必须运行在 Theme.MaterialComponents 或者 Theme.Material3 的派生主题下。最简单的方式就是全局改主题。如果项目原因不能全局改,可以用 ThemeOverlay 在局部兜底,比如给整屏布局设置:
android:theme="@style/ThemeOverlay.Material3.TextInputEditText.OutlinedBox"但这种局部覆盖方式容易漏掉其它控件,而且一旦嵌套复杂,排查起来会更痛苦。总之,能用全局解决的不要用局部。
4.4 颜色和暗黑模式
TextInputLayout 默认会根据主题自动适配浅色和深色模式,但如果你在属性里写了硬编码颜色,比如:
app:boxStrokeColor="#DDDDDD"那么在暗黑模式或者高对比度模式下,边框可能看不清或者显得突兀。合理的做法是引用颜色资源,并在 values-night 里给同一资源定义不同色值:
app:boxStrokeColor="@color/input_stroke_color"还有一类常见问题是背景色被主题调成了白色,导致暗黑模式下整个输入框亮得像一块补丁。遇到这种情况,优先检查 filled 模式的 boxBackgroundColor 是否用了主题属性,比如:
app:boxBackgroundColor="?attr/colorSurfaceVariant"用主题属性替代写死的颜色值,跨模式的表现就会自然很多。
5. 完整登录表单示例
5.1 布局 XML
下面是一份可以直接拷贝的登录表单布局,包含用户名输入框、密码输入框和登录按钮:
<LinearLayout 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" android:orientation="vertical" android:padding="16dp"> <com.google.android.material.textfield.TextInputLayout android:id="@+id/tilUsername" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="用户名" app:boxBackgroundMode="outline" app:boxCornerRadius="8dp" app:errorEnabled="true"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/etUsername" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="text" android:maxLines="1" /> </com.google.android.material.textfield.TextInputLayout> <com.google.android.material.textfield.TextInputLayout android:id="@+id/tilPassword" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="密码" app:boxBackgroundMode="outline" app:boxCornerRadius="8dp" app:errorEnabled="true" app:endIconMode="password_toggle"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/etPassword" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="textPassword" android:maxLines="1" /> </com.google.android.material.textfield.TextInputLayout> <Button android:id="@+id/btnLogin" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="登录" /> </LinearLayout>这个布局的亮点是:外层统一了提示、圆角、错误区域,密码框直接用 endIconMode 获得切换功能,不需要额外写代码。错误区域常驻 true,提交后不管有没有错误,下方都不会来回跳动。
5.2 Activity 校验逻辑
用 ViewBinding 拿控件,继续沿用刚才布局的 id,可以这样写:
class LoginActivity : AppCompatActivity() { private lateinit var binding: ActivityLoginBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityLoginBinding.inflate(layoutInflater) setContentView(binding.root) binding.btnLogin.setOnClickListener { validateLogin() } clearErrorWhenTyping(binding.etUsername, binding.tilUsername) clearErrorWhenTyping(binding.etPassword, binding.tilPassword) } private fun validateLogin() { val username = binding.etUsername.text?.toString()?.trim().orEmpty() val password = binding.etPassword.text?.toString().orEmpty() var valid = true if (username.isEmpty()) { binding.tilUsername.error = "请输入用户名" valid = false } if (password.isEmpty()) { binding.tilPassword.error = "请输入密码" valid = false } if (valid) { // 发起登录请求 } } private fun clearErrorWhenTyping( editText: TextInputEditText, layout: TextInputLayout ) { editText.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { if (s?.isNotEmpty() == true) { layout.error = null } } override fun afterTextChanged(s: Editable?) {} }) } }代码里所有文本提取不要读取 TextInputLayout 的 text 属性,虽然它内部有 editText,但直接读取子输入框的 text 更符合直觉,也不会因为容器状态引发空指针。
5.3 老项目替换的落地步骤
如果项目里已经有很多传统 EditText,想逐步迁移到 TextInputLayout,我建议按这个顺序操作:
- 全局主题切到 Theme.Material3 或 Theme.MaterialComponents。
- 在 build.gradle 确认已经引入 material 依赖。
- 布局级别逐个替换:把 EditText 标签换成 TextInputEditText。
- 外层包一层 TextInputLayout。
- 把原来的 android:hint 转移到 TextInputLayout。
- 删除原 EditText 的自定义 shape background,改用 boxBackgroundMode。
- 运行前用 lint 检查,查找还在引用原 EditText 的 id,把代码里的 findViewById 改为通过 binding.til.editText 获取。
迁移完成后,表单页面往往能少掉不少背景选择器和焦点监听代码,维护成本会明显下降。
6. 测试与扩展注意
6.1 Espresso 测试怎么定位输入框
Espresso 操作输入框时,直觉上是定位 TextInputEditText 的 id,但注意,TextInputLayout 和 TextInputEditText 是两个不同的 View,测试坐标需要区分:
onView(withId(R.id.etUsername)) .perform(replaceText("test")) onView(withId(R.id.tilUsername)) .check(matches(isDisplayed()))如果测试断言错误文案,用:
onView(withText("请输入用户名")) .check(matches(isDisplayed()))注意错误文字是通过 TextView 显示的,直接用 withText 匹配通常没问题。但如果有多个相同文案,需要配合 hasErrorText 之类的匹配器限定范围。
6.2 在 RecyclerView 或者多布局中使用
在列表页里每行一个输入框的场景,如果 item 复用,TextInputLayout 的错误状态一定要在 onBindViewHolder 里重置。否则上一行的“请输入内容”会被错误地带到下一行。
我踩过类似坑,当时的表现是滑动列表后,某些行自动出现了红色错误。原因就是 ViewHolder 复用了,旧 item 的错误状态没有清理。处理办法:
binding.tilNote.error = null binding.etNote.setText(item.value)先清 error 再 setText,顺序不能反。因为 setText 可能会触发 TextWatcher,如果错误没清,输入内容后 watcher 又把它隐藏了,逻辑看起来就很混乱。
还有一点,RecyclerView item 里的 TextInputLayout 不要随意使用 addTextChangedListener 而不移除,极容易内存泄漏。如果监听持有 Activity 引用,最好在 onViewDetachedFromWindow 里移除,或者改用注意生命周期的写法。
6.3 你值得养成的默认习惯
根据我个人的经验,日常写表单页时不妨把这几个习惯固定下来:
- 所有强调“提示”的东西,hint 一律写在外层 TextInputLayout 上。
- errorEnabled 在不需要动态显示错误的场景也默认开 true,省得后面布局跳。
- 密码框直接写 endIconMode="password_toggle",不要自己写 ImageView 去切换。
- 所有颜色尽量使用主题属性,少写死色值。
- 表单校验的逻辑抽到单独的函数里,不要在点击事件里堆一堆 if else。
这套组合看起来很简单,但真正能减少线上表单页的布局问题和适配问题。最后说一个小技巧:如果你发现 TextInputLayout 在低版本设备上浮动标签位置偏上,可能是 dp 相关尺寸不对,可以检查 app:boxCornerRadius 和输入框本身的 padding 是否冲突。Material 库已经做了很多适配,剩下的基本都是主题和资源引用的问题,沿着这条线排查就对了。