Skip to content
表单与交互(TextInput、键盘、焦点、权限)

表单与交互(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();
  };
}, []);

必须记住的三件事:

  1. 监听必须 remove()。忘记清理的后果不是内存泄漏那么简单——上一个页面的监听还在响应下一个页面的键盘事件,setState 打到已卸载组件、或者两个吸底输入框互相抢高度。
  2. Keyboard.isVisible() 可以同步读当前状态,但初始值在监听注册前可能已经弹起(autoFocus 场景),所以仍然以事件驱动为准,isVisible() 只做初始化兜底。
  3. 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。