# 弹窗与键盘避让设计指南 (Modal & Keyboard Avoidance) 本项目(React Native + Expo)所有底部弹窗(FormModal / BottomSheet / NumpadSheet)都基于 RN 原生 ``。Modal 会带来两个棘手问题:**底部安全区丢失** 和 **键盘遮挡**。本篇记录反复踩坑后总结的正确模式,避免重蹈覆辙。 参考实现:`reference_project/BeeCount`(Flutter,`AnimatedPadding + viewInsets.bottom` 方案)。 --- ## 1. 核心认知:`` 脱离主窗口 context > **这是所有问题的总根源,必须牢记。** RN 的 `` 会在原生层创建一个**独立的新窗口**,它**脱离了主窗口的 React 树**。两个直接后果: 1. **`react-native-safe-area-context` 失效**:主窗口里 expo-router 自动注入的 `SafeAreaProvider` 不会延伸到 Modal 内部。在 Modal 内直接调 `useSafeAreaInsets()` 会返回 `{ top: 0, bottom: 0, ... }`(全零),拿不到手势条高度。 2. **`KeyboardAvoidingView` 不可靠**:在 Modal 的独立窗口里,`behavior="height"` 在 Android 上键盘关闭后可能残留 padding(表现为"关闭键盘后弹窗底部有留白");`behavior="padding"` 对 Modal 内的布局响应也不稳定。 --- ## 2. 正确模式:Modal 内补 SafeAreaProvider + 响应式 paddingBottom ### 2.1 SafeAreaProvider 补救(解决底部安全区丢失) 每个 `` 内部**重新包一层 ``**,让 context 在弹窗内恢复有效: ```tsx // ❌ 错误:Modal 内直接用 useSafeAreaInsets(),返回全零 export function FormModal(props) { const insets = useSafeAreaInsets(); // bottom === 0 永远 return ...; } // ✅ 正确:Modal 内补 SafeAreaProvider,拆成外层 + Content export function FormModal(props) { return ( ); } function FormModalContent(props) { const insets = useSafeAreaInsets(); // 现在 bottom 是真实手势条高度 // ... } ``` > **为什么必须拆两个组件?** `useSafeAreaInsets()` 必须在 `` 的**子组件**里调用。同一个组件既渲染 Provider 又调 hook 会拿到全零(hook 在 Provider 上方执行)。 ### 2.2 响应式 paddingBottom(解决键盘遮挡,绝不残留) **核心思路(学 BeeCount 的 `AnimatedPadding + viewInsets.bottom`):键盘高度做成 paddingBottom,而不是位移 sheet。** ```tsx // useKeyboardHeight hook(见 src/hooks/useKeyboardAvoiding.ts) const kbHeight = useKeyboardHeight(); // sheet 的 paddingBottom = 基础留白 + 键盘高度 {/* 内容(含确认/取消按钮)会被 padding 顶起,始终在键盘上方 */} ``` **为什么这个模式正确:** - 键盘弹出 → `paddingBottom` 增大 → 内容被推到键盘上方,**且 sheet 仍贴底,不会露出遮罩缝** - 键盘关闭 → `paddingBottom` 精确回到 `Math.max(insets.bottom, 24)` → **无残留** - 初始(键盘未弹)→ 只有基础留白 → **不会一打开就有大留白** --- ## 3. ❌ 已验证的失败方案(不要再尝试) ### 3.1 `KeyboardAvoidingView` 包裹 sheet ```tsx // ❌ Modal 内 KAV 不可靠 ``` **问题**:Android `behavior="height"` 键盘关闭后残留 padding("关闭键盘后有留白");`behavior="position"` 在 Modal 内也不稳定。 ### 3.2 `Animated` translateY 位移整个 sheet ```tsx // ❌ 位移会露出遮罩缝 + Modal 内键盘事件易误触发 const translateY = useKeyboardAvoiding(); // 返回 Animated.Value ``` **两个致命问题**: 1. 位移后 sheet 原位置空出来,露出遮罩色 → **"键盘和弹窗之间有留白"** 2. Modal 首次渲染时若 `keyboardDidShow` 被残留焦点事件误触发,translateY 变负 → **"一打开就有留白"** > 这正是本项目踩过的坑:translateY 方案让用户看到"所有弹窗底部都有留白"。改用响应式 padding 后彻底解决。 --- ## 4. 底部留白值的正确计算 ```tsx // ❌ 错误:安全区 + 固定值叠加(会过大) paddingBottom: 36 + insets.bottom // 全面屏手势机 bottom≈48 → 总 84px,太多 // ✅ 正确:取较大值,不叠加 paddingBottom: Math.max(insets.bottom, 24) ``` **为什么不能叠加?** 安全区(`insets.bottom`)本身已经覆盖了手势条区域。再叠加一个固定的 36,会在底部堆出 80+px 的空白,视觉上是"一大块留白"。两者应取**较大值**——安全区大时取安全区,安全区为 0(如 MIUI 全面屏手势机)时取最小视觉留白。 --- ## 5. 项目中三个公共弹窗的实现 | 组件 | 文件 | 键盘避让 | 备注 | |---|---|---|---| | `FormModal` | `src/components/form/FormModal.tsx` | 响应式 paddingBottom(含 TextInput) | 所有管理页 CRUD 复用 | | `BottomSheet` | `src/components/ui/BottomSheet.tsx` | 响应式 paddingBottom + 手势下滑 Animated | 手势 `translateY` 仅用于下滑关闭,与键盘 padding 独立 | | `NumpadSheet` | `src/components/form/NumpadSheet.tsx` | 内部 ScrollView | 记一笔主面板,主要用自定义 NumpadKeyboard,系统键盘场景少 | `BottomSheet` 的特殊点:它同时有**手势下滑关闭**(用 `Animated.Value` 做 translateY)和**键盘避让**(用响应式 paddingBottom)。两者必须独立——手势位移走 Animated,键盘避让走 padding,不要合并成一个位移值(`Animated.add` 合并会导致手势和键盘互相干扰)。 --- ## 6. 居中弹窗(ConfirmDialog)无需处理 `ConfirmDialog`(删除确认)是居中浮层(`justifyContent: 'center'`),无 TextInput、不贴底、无键盘,**不受安全区/键盘影响,不需要上述任何处理**。 --- ## 7. 验证清单(修弹窗后必测) 1. **底部留白**:打开弹窗(不点输入框),底部只有正常小留白,无"一大块空白"。 2. **键盘上移**:点输入框唤起键盘 → 确认/取消按钮在键盘上方可见,且弹窗与键盘之间无缝隙。 3. **关闭键盘无残留**:收起键盘 → 弹窗精确回到初始状态,底部留白和打开时一致。 4. **全面屏手势机**:在 MIUI 等全面屏手势设备上(`insets.bottom = 0`)也正常。 --- ## 8. 经验教训 1. **RN `` 是独立窗口**——这是 RN 的设计,不是 bug。任何依赖主窗口 context 的 API(SafeArea、某些 Keyboard 行为)在 Modal 内都要重新建立。 2. **键盘避让用 padding,不用位移**——这是 Flutter/RN 通用共识(BeeCount 的 `AnimatedPadding + viewInsets`)。位移方案虽然直觉上像"弹窗上移",但会露出原位置,制造视觉缝隙。 3. **Hermes bundle 是二进制**——验证 release 包代码时必须 `grep -a`,否则永远误判"代码没进去"。详见 `android-build-guide.md §7.2`。