做 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 nameAppTheme parentTheme.Material3.DayNight.NoActionBar !-- 自定义颜色等属性 -- /style如果项目不想动全局主题也可以在现有主题上挂一个 overlay或者在根布局上单独设置 android:theme。但以我个人的经验最省心的还是全局改主题因为 Material 库的其他控件如 FloatingActionButton、BottomAppBar都对主题有同样的要求。2.2 最小可运行代码布局文件里最基础的写法长这样com.google.android.material.textfield.TextInputLayout android:idid/tilUsername android:layout_widthmatch_parent android:layout_heightwrap_content android:hint用户名 com.google.android.material.textfield.TextInputEditText android:idid/etUsername android:layout_widthmatch_parent android:layout_heightwrap_content android:inputTypetext / /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:boxStrokeColoroutline 模式下边框颜色可以用 selector 实现焦点变色app:boxStrokeWidthoutline 模式下边框宽度默认通常够用需要加粗时调整app:boxBackgroundColorfilled 模式背景色不要直接用硬编码色值app:hintEnabled是否启用浮动标签置 false 等价于普通静态 hintapp: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.comapp: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 上加 TextWatcheretUsername.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:passwordToggleEnabledtrue现在已经废弃推荐使用 endIconMode。在布局里写com.google.android.material.textfield.TextInputLayout android:idid/tilPassword android:layout_widthmatch_parent android:layout_heightwrap_content android:hint密码 app:endIconModepassword_toggle com.google.android.material.textfield.TextInputEditText android:idid/etPassword android:layout_widthmatch_parent android:layout_heightwrap_content android:inputTypetextPassword / /com.google.android.material.textfield.TextInputLayoutTextInputLayout 会根据子输入框的 inputType 自动切换眼睛图标状态。当输入框当前是 textPassword 时显示“明文”图标点击后变成普通 text图标变为“隐藏”样式。这里你其实不需要关心内部光标位置Material 已经处理得比较完善用户点击切换不会丢焦点。如果项目里想自定义眼睛图标可以设置 app:endIconDrawable 指向一个 selector里面用状态不同区分明文和密文。图标需要遵循 Material 的图标规范尺寸建议 24dp。3.4 辅助文本、字符计数与前缀后缀辅助文本适合放一些“密码必须包含大小写”之类的格式说明。和错误提示叠加时错误信息的优先级更高会自动覆盖辅助文本错误消失后辅助文本会恢复显示。布局里可以这样app:helperText8-16位字母或数字 app:counterEnabledtrue app:counterMaxLength16counterEnabled 开启后TextInputLayout 右下角会显示“0/16”这样的计数。需要注意的是counterMaxLength 只是计数上限真正限制输入长度还是要靠 TextInputEditText 上的 android:maxLength否则用户输入超过 16 位后计数会变红但内容仍然会继续输入。前缀后缀则是我很喜欢的功能做手机号填空时可以写app:prefixText86 app:suffixTextqq.com前缀和后缀的文本会永远显示在输入框内不会随着输入内容滚动消失这对表达固定单位非常方便。3.5 圆角与描边样式老项目里常见的做法是给 EditText 设一个自定义 shape background做圆角边框。用 TextInputLayout 之后可以直接用 boxBackgroundMode省掉 shape。想要圆角外框风格app:boxBackgroundModeoutline app:boxCornerRadius8dp想要仿 iOS 白色填充风格app:boxBackgroundModefilled app:boxBackgroundColor#F5F5F5焦点变化时边框变色需要把 boxStrokeColor 改成 selectorselector xmlns:androidhttp://schemas.android.com/apk/res/android item android:color#2196F3 android:state_focusedtrue/ 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:themestyle/ThemeOverlay.Material3.TextInputEditText.OutlinedBox但这种局部覆盖方式容易漏掉其它控件而且一旦嵌套复杂排查起来会更痛苦。总之能用全局解决的不要用局部。4.4 颜色和暗黑模式TextInputLayout 默认会根据主题自动适配浅色和深色模式但如果你在属性里写了硬编码颜色比如app:boxStrokeColor#DDDDDD那么在暗黑模式或者高对比度模式下边框可能看不清或者显得突兀。合理的做法是引用颜色资源并在 values-night 里给同一资源定义不同色值app:boxStrokeColorcolor/input_stroke_color还有一类常见问题是背景色被主题调成了白色导致暗黑模式下整个输入框亮得像一块补丁。遇到这种情况优先检查 filled 模式的 boxBackgroundColor 是否用了主题属性比如app:boxBackgroundColor?attr/colorSurfaceVariant用主题属性替代写死的颜色值跨模式的表现就会自然很多。5. 完整登录表单示例5.1 布局 XML下面是一份可以直接拷贝的登录表单布局包含用户名输入框、密码输入框和登录按钮LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical android:padding16dp com.google.android.material.textfield.TextInputLayout android:idid/tilUsername android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_marginTop12dp android:hint用户名 app:boxBackgroundModeoutline app:boxCornerRadius8dp app:errorEnabledtrue com.google.android.material.textfield.TextInputEditText android:idid/etUsername android:layout_widthmatch_parent android:layout_heightwrap_content android:inputTypetext android:maxLines1 / /com.google.android.material.textfield.TextInputLayout com.google.android.material.textfield.TextInputLayout android:idid/tilPassword android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_marginTop12dp android:hint密码 app:boxBackgroundModeoutline app:boxCornerRadius8dp app:errorEnabledtrue app:endIconModepassword_toggle com.google.android.material.textfield.TextInputEditText android:idid/etPassword android:layout_widthmatch_parent android:layout_heightwrap_content android:inputTypetextPassword android:maxLines1 / /com.google.android.material.textfield.TextInputLayout Button android:idid/btnLogin android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_marginTop24dp 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省得后面布局跳。密码框直接写 endIconModepassword_toggle不要自己写 ImageView 去切换。所有颜色尽量使用主题属性少写死色值。表单校验的逻辑抽到单独的函数里不要在点击事件里堆一堆 if else。这套组合看起来很简单但真正能减少线上表单页的布局问题和适配问题。最后说一个小技巧如果你发现 TextInputLayout 在低版本设备上浮动标签位置偏上可能是 dp 相关尺寸不对可以检查 app:boxCornerRadius 和输入框本身的 padding 是否冲突。Material 库已经做了很多适配剩下的基本都是主题和资源引用的问题沿着这条线排查就对了。