表单与交互(TextInput、键盘、焦点、权限)
表单是移动端体验的"细节密集区":Web 上表单不好用顶多是难受,移动端上键盘遮挡、输入法串字、自动填充失效会直接导致注册流失。这一章按"写一个真实注册/登录表单会碰到的所有事"来组织:TextInput 参数实战、受控与非受控的性能取舍、焦点与键盘联动、选择类控件、按钮防重复提交、图片相机权限,最后是校验与滚动的配合。
注意 本章代码基线 RN 0.88 / Expo SDK 58。RN 0.88 已正式弃用
InputAccessoryView(键盘上方的自定义辅助条),需要"上一项/下一项"键盘工具栏的场景,优先用onSubmitEditing链式跳转解决,见焦点管理一节。
TextInput 实战
TextInput 是 RN 长期稳定的核心组件,参数不多但每个都有平台陷阱。先给一个真实注册页的完整示例,再逐项拆解。
// src/screens/RegisterScreen.tsx
import React, { useRef, useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
export default function RegisterScreen() {
const [email, setEmail] = useState('');
const [nickname, setNickname] = useState('');
const [bio, setBio] = useState('');
const [showPassword, setShowPassword] = useState(false);
const emailRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
const nicknameRef = useRef<TextInput>(null);
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={{ flexGrow: 1, padding: 16 }}>
<Text style={styles.label}>邮箱</Text>
<TextInput
ref={emailRef}
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
textContentType="emailAddress"
autoComplete="email"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
blurOnSubmit={false}
placeholder="you@example.com"
placeholderTextColor="#999"
style={styles.input}
/>
<Text style={styles.label}>密码(至少 8 位)</Text>
<View style={styles.passwordRow}>
<TextInput
ref={passwordRef}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
textContentType="password"
returnKeyType="next"
onSubmitEditing={() => nicknameRef.current?.focus()}
blurOnSubmit={false}
style={[styles.input, { flex: 1 }]}
/>
<Pressable onPress={() => setShowPassword((v) => !v)} hitSlop={12} style={styles.eye}>
<Text>{showPassword ? '隐藏' : '显示'}</Text>
</Pressable>
</View>
<Text style={styles.label}>昵称(最多 12 字)</Text>
<TextInput
ref={nicknameRef}
value={nickname}
onChangeText={setNickname}
maxLength={12}
returnKeyType="next"
onSubmitEditing={() => { /* 聚焦下一项 */ }}
blurOnSubmit={false}
style={styles.input}
/>
<Text style={styles.counter}>{nickname.length} / 12</Text>
<Text style={styles.label}>个人简介</Text>
<TextInput
value={bio}
onChangeText={setBio}
multiline
numberOfLines={4}
textAlignVertical="top"
style={[styles.input, styles.multiline]}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
label: { fontSize: 13, color: '#666', marginTop: 16, marginBottom: 6 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
minHeight: 44,
},
multiline: { height: 96, paddingTop: 10 },
passwordRow: { flexDirection: 'row', alignItems: 'center' },
eye: { marginLeft: 8 },
counter: { alignSelf: 'flex-end', fontSize: 12, color: '#999', marginTop: 4 },
});keyboardType:分平台记
取值集(iOS/Android 通用部分):default、email-address、numeric、phone-pad、number-pad、decimal-pad、url;iOS 独有 ascii-capable、numbers-and-punctuation、name-phone-pad、twitter、web-search;Android 独有 visible-password。
实战取舍和差异:
- 手机号用
phone-pad,不要用numeric。numeric在 Android 上是旧式纯数字键盘,phone-pad带拨号符号(+ * #)且是国际表单的标准姿势。 - Android 上
number-pad和decimal-pad行为不如 iOS 稳定:不同厂商键盘表现不一致(小数点位置、是否弹出负号),金额输入建议decimal-pad+ 提交前正则校验兜底,把键盘当"提示"而不是"约束"。 - 邮箱用
email-address时务必配autoCapitalize="none" + autoCorrect={false},否则 iOS 自动首字母大写、Android 联想词会污染输入。 - 想把软键盘彻底收起来自己画数字面板(支付密码场景):
showSoftInputOnFocus={false}让聚焦不弹系统键盘,配合自定义键盘组件。
returnKeyType + onSubmitEditing + blurOnSubmit
三件套是多输入表单不用点屏幕切焦点的关键:
returnKeyType只是改回车键文案(default / go / next / search / send / done),next场景必须在onSubmitEditing里真的把焦点移走;blurOnSubmit={false}阻止提交时键盘收起——否则"回车 → 键盘收起 → 再弹出聚焦下一个",动画抖一下,体验碎。注意 iOS 上multiline输入框会强制忽略blurOnSubmit={false}(回车换行本来就是它的语义)。- 表单最后一项用
returnKeyType="go"或done+blurOnSubmit={true},回车直接收起键盘并提交。
注意 iOS 15+ 系统设置里"按键返回以收起键盘"等行为、以及第三方输入法(搜狗/微信键盘)对 return 键事件的转发不完全一致,
onSubmitEditing偶发不触发属于外部因素。对可靠性敏感的提交动作不要只挂在回车上,页面主按钮永远要在。
secureTextEntry 与自动填充(密码管理器)
// 新密码输入框:让 iOS 钥匙串弹出"强密码建议"并正确保存
<TextInput
secureTextEntry
autoCapitalize="none"
textContentType="newPassword"
autoComplete="new-password"
/>
// 验证码输入框:iOS 自动读取短信验证码
<TextInput
keyboardType="number-pad"
textContentType="oneTimeCode"
autoComplete="sms-otp"
maxLength={6}
/>textContentType 是 iOS 侧的自动填充语义(值来自系统的 UITextContentType),常用:username、password(登录)、newPassword(注册/改密)、oneTimeCode、emailAddress、name、telephoneNumber、creditCardNumber。Android 侧对应的是 autoComplete(Web 语义:password、new-password、email、sms-otp 等),两个都写、互为兜底,是当前跨平台自动填充的标准做法。
为什么重要:secureTextEntry 的密文输入框如果没标对 textContentType,iOS 钥匙串不会建议/回填强密码,用户被迫手输——注册转化率的一个真实杀手。另外 password 和 newPassword 千万别标反:标反的框会让密码管理器把登录密码存成新密码,下次登录一片祥和地失败。
切换明文/密文(上图的"显示"按钮)在 iOS 上有个小毛病:secureTextEntry 翻转时光标会跳到末尾。如果用户在密码中间修改,翻转后手动点回原位置是常见补救,或接受这个行为(大多数用户不编辑中间)。
multiline:自动增高,但没有 autoGrow
multiline 的 TextInput 默认就会随内容增高——RN 核心没有 autoGrow 这个属性,那是 React Native Web 和社区库的叫法,写进 RN 代码会无声无效。控制高度的正确姿势:
const [bioHeight, setBioHeight] = useState(44);
<TextInput
multiline
onContentSizeChange={(e) => {
// 内容高度夹在 44 ~ 160 之间,超过封顶后内部滚动
setBioHeight(Math.min(160, Math.max(44, e.nativeEvent.contentSize.height)));
}}
style={{ height: bioHeight }}
/>textAlignVertical="top" 记得加,否则 Android 上多行内容默认垂直居中,很怪。
maxLength 与字数统计
maxLength 两端都稳定(软键盘无法再输入),但没有内置的 showCount——计数器自己渲染 {value.length} / {max}(见上文昵称一项)。两个坑:
- 长度按 UTF-16 码元计,一个 emoji 常占 2,
maxLength={10}实际能敲进 5 个表情。统计文案想贴近用户直觉,自己用Array.from(value).length数码点(ZWJ 组合表情仍然数多,接受即可,别为它引入分素库)。 - 粘贴超长文本会被静默截断,不报错。对"最多 N 字"有合规含义的场景(签名、评论),提交前再校验一次真实长度。
受控 vs 非受控:RN 表单的性能现实
先给结论,再解释:短表单(2~4 个输入)全部受控随便写;长表单/带联动的表单,要么把状态下沉到叶子组件,要么干脆非受控,要么上 react-hook-form。
原因很朴素:受控意味着每次按键 setState → 当前组件重渲染。一个 20 个输入框、每个框 20 次击键的表单,就是 400 次整棵子树的重渲染,RN 还要把 diff 结果过桥提交给原生——低端 Android 上打字掉帧。这和 Web 不同,Web 上同样的模式有 react-hook-form 手册里"注册式非受控"的标准解(见 ../react/04-事件处理与表单.md),RN 的解法分三档:
// 档位一:状态下沉。每个输入自己管自己,父组件不因为打字重渲染
function LabeledInput({
label,
defaultValue = '',
onCommit,
}: {
label: string;
defaultValue?: string;
onCommit: (v: string) => void;
}) {
const [v, setV] = useState(defaultValue);
return (
<View>
<Text style={styles.label}>{label}</Text>
<TextInput value={v} onChangeText={setV} onBlur={() => onCommit(v)} style={styles.input} />
</View>
);
}
// 档位二:非受控。挂载时给 defaultValue,值只存 ref,提交时收网
function UncontrolledInput({ inputRef }: { inputRef: React.RefObject<TextInput | null> }) {
const valueRef = useRef('');
return (
<TextInput
ref={inputRef}
defaultValue=""
onChangeText={(t) => {
valueRef.current = t; // 不 setState,零重渲染
}}
style={styles.input}
/>
);
// 提交时读 valueRef.current。别用 inputRef.current.value——RN 的 TextInput
// 命令式句柄只有 focus/blur/clear/isFocused/setSelection,没有 value 读取。
}
// 档位三:react-hook-form(RN 可用,Controller 包装 TextInput)
// <Controller control={control} name="email" render={({ field }) =>
// <TextInput value={field.value} onChangeText={field.onBlur /* 等 */} /> } />
// 字段级订阅,校验规则复用 Web 心智。
三个约束补充:defaultValue 只在挂载时生效,后续改它不会刷新视图——异步数据回填表单时,要么数据到齐后再渲染该组件,要么用受控 value。受控 value 从有值突变成 undefined 会让输入框变成非受控并告警,写 value={v ?? ''}。跨端代码(React Native Web)里 onChange={(e) => e.nativeEvent.text} 的写法在 RN 上不成立,统一用 onChangeText。
焦点管理与键盘遮挡
输入框之间跳焦点
ref.focus() / ref.blur() 是 TextInput 命令式句柄的固定动作,配合 onSubmitEditing 就是"回车跳下一项"的完整链路(上文注册页已示范:emailRef → passwordRef → nicknameRef)。要点:
ref类型写useRef<TextInput>(null),调用用可选链ref.current?.focus()(StrictMode 下 ref 可能为空)。- 密码确认这类"下一项还是密文框"的场景,跳转后键盘收起/弹出闪烁最明显,所以链路里每个框都配
blurOnSubmit={false}。 - 想让程序化聚焦但不弹键盘:
focus()无法抑制键盘,只能收起后再调用;真正的"只聚焦不打字"用isFocused()判断配合onFocus/onBlur驱动 UI(光标闪烁、字段高亮)。 - 自定义键盘辅助条(键盘顶上悬浮"上一项/下一个"工具栏)曾经靠
InputAccessoryView,RN 0.88 已弃用它——新项目直接用 return 键链式跳转,别为这个场景找替代库钉版本。
KeyboardAvoidingView:双平台稳妥配方
键盘弹起遮挡底部输入框,是移动端表单的"必修课"。KeyboardAvoidingView 的 behavior 有三个取值:'height' | 'position' | 'padding',两个平台的答案不一样:
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight : 0}
>
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
{/* 表单内容 */}
</ScrollView>
</KeyboardAvoidingView>- iOS 用
behavior="padding":键盘弹出时给容器补底部 padding,配合外层 ScrollView 就能滚开遮挡。keyboardVerticalOffset只在上方有固定高度元素(自定义 header、tab 栏)时调整,多试一次真机就能定准。 - Android 不要开 KAV,让系统 resize:在
AndroidManifest.xml的 activity 上配android:windowSoftInputMode="adjustResize"(Expo 项目在app.json用"android": { "softwareKeyboardLayoutMode": "resize" }声明)。键盘出现时窗口本身缩小,底部输入框自然被顶上来,不需要 JS 参与,也就没有 KAV 在 Android 上动画不同步的经典毛病。 - Android 万不得已才用
behavior="height"(键盘高度变化改容器高度),低端机闪烁明显。
Warning Android 15+ 强制 edge-to-edge 之后,
adjustResize与系统栏 insets 的交互变复杂:有的设备键盘高度重复计入导航栏 padding,出现"双重顶起"或底部留白过大。这条链路 RN 与 Expo 模板还在持续收敛,改完必须真机回归(不同厂商表现不一致,模拟器测不出来)。edge-to-edge 的整体背景见04-导航.md平台细节一节。
聚焦的输入框自动滚到可视区
KAV + ScrollView 只解决"有地方滚",还差"滚过去"。最省事的办法是让焦点驱动滚动:
const scrollRef = useRef<ScrollView>(null);
// ScrollView 上较新版本 iOS 提供了 automaticallyAdjustKeyboardInsets
// 打开后系统自动处理键盘 insets 与滚动,优先用它替代手写联动:
<ScrollView ref={scrollRef} automaticallyAdjustKeyboardInsets>不依赖该属性的通用写法:给每个输入框挂 onFocus,用 scrollRef.current?.scrollToEnd()(聚焦总在末尾时够用)或测量坐标后 scrollTo 精确位置——测量要经过 measureInWindow 异步回调,键盘动画期间坐标会漂移,手写这条链路的团队都懂它在低端机上有多不可靠,所以能用系统属性就用系统属性。
Keyboard 模块
Keyboard 是长期稳定的原生事件桥:
import { Keyboard, Platform } from 'react-native';
const [keyboardVisible, setKeyboardVisible] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
useEffect(() => {
// iOS 有 will 系(动画前触发),Android 只有 did 系——监听名按平台选
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const s1 = Keyboard.addListener(showEvent, (e) => {
setKeyboardVisible(true);
setKeyboardHeight(e.endCoordinates.height); // 含安全区的高度,做吸底输入框用
});
const s2 = Keyboard.addListener(hideEvent, () => {
setKeyboardVisible(false);
setKeyboardHeight(0);
});
return () => {
s1.remove();
s2.remove();
};
}, []);必须记住的三件事:
- 监听必须
remove()。忘记清理的后果不是内存泄漏那么简单——上一个页面的监听还在响应下一个页面的键盘事件,setState 打到已卸载组件、或者两个吸底输入框互相抢高度。 Keyboard.isVisible()可以同步读当前状态,但初始值在监听注册前可能已经弹起(autoFocus场景),所以仍然以事件驱动为准,isVisible()只做初始化兜底。Keyboard.dismiss()只对由 RN 管理的焦点生效。第三方原生输入(内嵌 WebView、原生地图搜索框、部分 SDK 的输入组件)自己持有焦点,dismiss 无效——这类场景找各自 API,别在 RN 侧硬调。
选择类交互
Switch
<Switch
value={notifications}
onValueChange={setNotifications}
trackColor={{ false: '#d1d1d6', true: '#34c759' }}
thumbColor={notifications ? '#ffffff' : '#f4f3f4'}
ios_backgroundColor="#e5e5ea"
/>onValueChange 给新值不是旧值;trackColor 的 false 态记得给可读的中灰,深色模式下 ios_backgroundColor 不配会在 iOS 上留白边。
Picker / CheckBox / Slider 为什么不在核心里
历史课一段,解释"RN 组件永远齐全"这个预期为什么不成立:RN 0.62 前后,官方把 Picker、CheckBox 这类在各平台样式和行为差异太大、官方无力统一维护的组件从核心迁出到 @react-native-community 命名空间(Picker 后来进一步合并进 segmented-control 等社区包),Slider 同样迁到了社区包(@react-native-community/slider)。这是 RN 团队"核心只做渲染管线、控件交给生态"战略的体现,不是弃用事故。后果是老教程里的 import { Picker } from 'react-native' 今天直接编译失败——遇到这类 import 报错,先搜"组件名 + react-native-community"。
下拉选择框的现实方案
今天写一个选项选择器,社区没有事实标准的"Dropdown",主流做法两条:
- 选项 ≤ 10 个、要求快速交付:
Pressable触发 + 底部弹出列表。用Modal(transparent+animationType="fade",列表放在底部对齐的 View 里)自己搭,二十行搞定,跨平台行为完全可控——这是我最推荐的默认姿势。 - 要求跟手动画、可拖拽:直接用 BottomSheet 形态的社区库(选型时看它对新架构/gesture-handler 的支持度,版本号跟着 SDK 兼容表走,这里不钉)。
原生体验提醒:iOS 用户期待底部滚轮或全列表,Android 用户期待 Spinner 对话框——但绝大多数商业 App 两端用同一套自绘底部弹层,一致性优先,这不算平台违规。
按钮与按压态:复用 02 章 + 防重复提交
按压态的结论直接复用 02-核心组件.md 里 Pressable 的部分:style={({ pressed }) => [...]} 函数态给 opacity 或 scale 变化,比 TouchableOpacity 的默认透明度更可控。这里只补表单场景的必修课——提交按钮的防重复:
function SubmitButton({
onPress,
children,
}: {
onPress: () => Promise<void>;
children: React.ReactNode;
}) {
const [isPending, setIsPending] = useState(false);
const handlePress = async () => {
if (isPending) return;
setIsPending(true);
try {
await onPress();
} finally {
setIsPending(false); // 失败也要恢复按钮,否则用户对着死按钮怀疑人生
}
};
return (
<Pressable
onPress={handlePress}
disabled={isPending}
style={({ pressed }) => [
styles.button,
pressed && { opacity: 0.85 },
isPending && { opacity: 0.6 },
]}
>
{isPending ? (
<ActivityIndicator color="#ffffff" />
) : (
<Text style={styles.buttonText}>{children}</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
backgroundColor: '#0a7',
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
marginTop: 24,
},
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});disabled + 本地 isPending 双保险是标准写法:disabled 拦住重复 onPress,isPending 驱动 loading 视觉。提交成功要离开当前屏幕时(见 04-导航.md 的 replace/reset),注意 finally 里 setState 发生在卸载后——React 18 起不再告警但仍是无意义渲染,可以把恢复逻辑放进 if (!cancelled) 守卫。服务端幂等设计见 05-数据获取与网络.md,按钮防重只是第一道闸。
手势与动画交互:路标一句
拖拽排序、捏合缩放、滑动删除这类交互不要用 Touchable/Responder 手搓,统一留给 07-动画与手势.md 选型(Reanimated + Gesture Handler 的组合已是社区默认)。本章只需要知道边界:TextInput 的滚动、KeyboardAvoidingView 的联动是"系统行为",其余"用户手指驱动的连续动画"都归 07。
图片 / 相机 / 权限三件套
表单里最常见的富交互是头像/凭证上传。Expo 项目用 expo-image-picker,完整流程(选图 → 预览 → 上传):
import * as ImagePicker from 'expo-image-picker';
async function pickImage() {
// 1. 运行时请求相册权限(Android 13+ 会自动落到 READ_MEDIA_IMAGES 细粒度权限)
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert('无法访问相册', '请在系统设置中开启相册权限');
return;
}
// 2. 拉起系统图片选择器(较新 SDK 的 mediaTypes 用字符串数组)
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.8, // 压缩输出,0~1
});
if (result.canceled) return;
// 3. 预览 + 上传
const asset = result.assets[0];
setPreviewUri(asset.uri);
await uploadImage(asset.uri); // multipart 上传见 05-数据获取与网络.md
}
// 拍照版本:requestCameraPermissionsAsync + launchCameraAsync,其余同构
bare 项目同逻辑用 react-native-image-picker 等社区库,差异主要在 Android 权限要自己过 PermissionsAndroid,流程一致。
权限的三处配置,缺一处审核或运行就翻车:
- AndroidManifest(bare 或 expo prebuild 后):Android 13+ 相册读取是
READ_MEDIA_IMAGES/READ_MEDIA_VIDEO细粒度权限,旧整包READ_EXTERNAL_STORAGE在 13+ 失效;相机是CAMERA。Expo 项目由插件在构建时注入,不要手改 prebuild 产物。 - iOS Info.plist:
NSPhotoLibraryUsageDescription、NSCameraUsageDescription必填,文案必须说清用途——“使用相机"这种裸文案是 App Store 高频拒项(Guideline 5.1.1),写"用于拍摄并上传您的头像照片”。Expo 在app.json的plugins配置expo-image-picker的 photos/camera 权限字符串。 - 拒绝后的路:iOS 拒绝过一次后系统弹窗永久不再出现,必须
Linking.openURL(UIApplicationOpenSettingsURLString)(Expo 封装在expo-linking)引导去设置页,这条兜底 UI 要在设计稿里就存在。
预览用 expo-image(或 02 章推荐的 Image 方案),上传的 FormData 写法、超时与重试在 05-数据获取与网络.md;本地暂存选中的文件路径(用户中途退出要能恢复草稿)配合 08-存储与文件.md。
表单验证模式
思路沿用 Web 端:schema 定义规则,提交时整体校验,错误落到字段级 state,行内展示。zod 为例(yup 同构,superRefine 换成 refine):
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().min(1, '请输入邮箱').email('邮箱格式不正确'),
password: z.string().min(8, '密码至少 8 位'),
nickname: z.string().min(2, '昵称至少 2 个字符').max(12, '昵称最多 12 个字符'),
});
type RegisterValues = z.infer<typeof registerSchema>;
function useRegisterForm() {
const [values, setValues] = useState<RegisterValues>({ email: '', password: '', nickname: '' });
const [errors, setErrors] = useState<Partial<Record<keyof RegisterValues, string>>>({});
function submit(): boolean {
const parsed = registerSchema.safeParse(values);
if (!parsed.success) {
const next: Partial<Record<keyof RegisterValues, string>> = {};
for (const issue of parsed.error.issues) {
const key = issue.path[0] as keyof RegisterValues;
if (!next[key]) next[key] = issue.message; // 每字段只留第一条错误
}
setErrors(next);
return false;
}
// 调接口……
return true;
}
return { values, setValues, errors, submit };
}
// 行内错误展示组件:错误文案出现在字段下方,不弹全局 toast
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<View style={{ marginBottom: 4 }}>
<Text style={styles.label}>{label}</Text>
{children}
{error ? (
<Text style={{ color: '#d33', fontSize: 12, marginTop: 4 }}>{error}</Text>
) : null}
</View>
);
}时机策略(移动端共识):提交时整体校验是底线,onBlur 校验是加分,onChange 实时校验只对"密码强度"这类信息性反馈——边打字边报红会把用户逼疯,尤其中文输入还在组字阶段就校验更是灾难(见下一条坑清单)。
校验失败后滚动定位到第一个错误字段:和焦点一节同一条链路,scrollTo 配合字段坐标测量。能接受"聚焦第一个错误输入框 + 键盘弹起"的替代方案就选它,可靠性高一个数量级。
坑清单
value与defaultValue混用:给value就必须给onChangeText且 state 初值不能是undefined;组件生命周期内在受控/非受控之间切换会触发 React 告警并让输入框"失忆"。异步回填永远value={v ?? ''}。- 受控 TextInput 撞中文输入法:RN 没有
compositionstart/end事件(Web 有),Android 上拼音候选期间组词文本会被onChangeText回灌进value,受控回写会造成候选字闪烁、选词被吞、退格删错一类经典问题。缓解手段:长中文输入字段用非受控(defaultValue+ 提交时取值),或接受偶发不顺畅并把 maxLength 校验挪到提交时。这是平台级限制,不是你的代码写错了。 - 动态切换
keyboardType的代价:输入框聚焦时从default改number-pad,iOS 会重新弹起整个键盘(高度不同、有动画),配合 KAV 就是"页面抖一下"。规则:每个输入框的 keyboardType 定死,数字/文字混合需求拆成两个输入框,别在聚焦状态热切。 - Android
adjustResize与 edge-to-edge 的交互:见 KAV 一节的 Warning——Android 15+ 上键盘 insets 与系统栏 insets 可能叠加,模拟器复现不了,必须真机回归;Expo SDK 58 模板已处理大半,自定义 windowSoftInputMode 前先确认模板默认值。 secureTextEntry翻转光标跳末尾:显示/隐藏密码切换的固有行为,用户正在编辑中间时体验突兀,低频场景可接受,高频密码修改考虑"两个独立输入框(密文 + 明文预览)“布局。- 监听泄漏:
Keyboard.addListener/AppState等原生事件监听没在卸载时remove(),表现是"离开页面后表单还在偷偷 setState”。React Native 的桥事件不随组件卸载自动解绑,这条纪律写进 code review 清单(12-常见陷阱与最佳实践.md有汇总表)。
实战建议
把表单当"键盘上的交互系统"来设计,而不是几个 TextInput 的堆叠:先定键盘类型(每个输入框的 keyboardType/returnKeyType/textContentType 三件套在 PR 里就该可见),再定焦点链路(回车往哪跳、最后一项干什么),最后才是校验和提交。顺序反了,你会发现返工都在"键盘弹出遮挡了按钮"这类本可提前设计掉的问题上。
性能上接受一个朴素分工:短表单全受控怎么写都不亏,长表单第一天就上叶子组件下沉或 react-hook-form,不要等卡顿报告来了再重构——受控改非受控要动回填逻辑、校验时机、受控 value 的所有依赖方,是一次中型重构。
发布前用真机过四个场景:搜狗/微信键盘 + 中文输入下昵称框打十个字;密码自动填充(钥匙串/Google Password Manager)在注册和登录两条链路都走通;Android 13+ 与 iOS 17+ 各拒绝一次权限、再进设置开权限重试;横竖屏旋转 + 键盘同时在场。这四条通过,表单体验就超过市面上大半 App。