# 弹窗与键盘避让设计指南 (Modal & Keyboard Avoidance)

本项目（React Native + Expo）所有底部弹窗（FormModal / BottomSheet / NumpadSheet）都基于 RN 原生 `<Modal>`。Modal 会带来两个棘手问题：**底部安全区丢失** 和 **键盘遮挡**。本篇记录反复踩坑后总结的正确模式，避免重蹈覆辙。

参考实现：`reference_project/BeeCount`（Flutter，`AnimatedPadding + viewInsets.bottom` 方案）。

---

## 1. 核心认知：`<Modal>` 脱离主窗口 context

> **这是所有问题的总根源，必须牢记。**

RN 的 `<Modal>` 会在原生层创建一个**独立的新窗口**，它**脱离了主窗口的 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 补救（解决底部安全区丢失）

每个 `<Modal>` 内部**重新包一层 `<SafeAreaProvider>`**，让 context 在弹窗内恢复有效：

```tsx
// ❌ 错误：Modal 内直接用 useSafeAreaInsets()，返回全零
export function FormModal(props) {
  const insets = useSafeAreaInsets(); // bottom === 0 永远
  return <Modal>...</Modal>;
}

// ✅ 正确：Modal 内补 SafeAreaProvider，拆成外层 + Content
export function FormModal(props) {
  return (
    <Modal visible={props.visible} transparent animationType="slide">
      <SafeAreaProvider>
        <FormModalContent {...props} />
      </SafeAreaProvider>
    </Modal>
  );
}
function FormModalContent(props) {
  const insets = useSafeAreaInsets(); // 现在 bottom 是真实手势条高度
  // ...
}
```

> **为什么必须拆两个组件？** `useSafeAreaInsets()` 必须在 `<SafeAreaProvider>` 的**子组件**里调用。同一个组件既渲染 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 = 基础留白 + 键盘高度
<Pressable style={{
  paddingBottom: Math.max(insets.bottom, 24) + kbHeight,
}}>
  {/* 内容（含确认/取消按钮）会被 padding 顶起，始终在键盘上方 */}
</Pressable>
```

**为什么这个模式正确：**
- 键盘弹出 → `paddingBottom` 增大 → 内容被推到键盘上方，**且 sheet 仍贴底，不会露出遮罩缝**
- 键盘关闭 → `paddingBottom` 精确回到 `Math.max(insets.bottom, 24)` → **无残留**
- 初始（键盘未弹）→ 只有基础留白 → **不会一打开就有大留白**

---

## 3. ❌ 已验证的失败方案（不要再尝试）

### 3.1 `KeyboardAvoidingView` 包裹 sheet

```tsx
// ❌ Modal 内 KAV 不可靠
<Modal>
  <SafeAreaProvider>
    <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
      <Sheet/>
    </KeyboardAvoidingView>
  </SafeAreaProvider>
</Modal>
```
**问题**：Android `behavior="height"` 键盘关闭后残留 padding（"关闭键盘后有留白"）；`behavior="position"` 在 Modal 内也不稳定。

### 3.2 `Animated` translateY 位移整个 sheet

```tsx
// ❌ 位移会露出遮罩缝 + Modal 内键盘事件易误触发
const translateY = useKeyboardAvoiding(); // 返回 Animated.Value
<Animated.View style={{ transform: [{ translateY }] }}>
  <Sheet/>
</Animated.View>
```
**两个致命问题**：
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 `<Modal>` 是独立窗口**——这是 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`。
