Skip to content

Commit 66e6278

Browse files
thomasttvothomasvoclaude
authored
fix: add automaticOffset prop for correct KAV positioning in modals (#1346)
## Problem `KeyboardAvoidingView` doesn't work correctly in `pageSheet` modals **on iOS** because `onLayout` returns parent-relative coordinates. In a modal, the parent's `y=0` doesn't correspond to screen `y=0`, so the keyboard overlap calculation uses incorrect positions — resulting in the keyboard covering the input or the view being offset incorrectly. This is an iOS-specific issue. On Android, modals use a separate `Dialog` window, but `measureInWindow` (backed by `getLocationOnScreen()`) already returns correct absolute screen coordinates regardless of which window the view is in. ## Solution Add an opt-in `automaticOffset` prop (default `false`) that gates the `measureInWindow` behavior. This preserves backward compatibility — existing users who set `keyboardVerticalOffset` to their header height continue to work unchanged. When `automaticOffset={true}`: - Uses `measureInWindow` instead of `onLayout` to get absolute screen coordinates, so the view's position is correctly detected in modals, behind navigation headers, etc. - `keyboardVerticalOffset` becomes purely **additive** extra space rather than compensation for unknown positioning — the default of `0` works correctly out of the box. When `automaticOffset={false}` (default): - Behavior is identical to before this PR — uses `onLayout` parent-relative coordinates, and `keyboardVerticalOffset` must be set to the header height manually. Uses the existing `useCombinedRef` hook to maintain both the internal ref (needed for `measureInWindow`) and the forwarded ref from the consumer. ## Example app Added a new **"KeyboardAvoidingView Automatic"** example screen (`KeyboardAvoidingViewAutomatic`) to showcase `automaticOffset` behavior: - Demonstrates all three behavior modes (padding, height, position) - Auto/Manual toggle to compare `automaticOffset={true}` vs `automaticOffset={false}` - Includes a `pageSheet` Modal to test the modal positioning fix - Configurable `keyboardVerticalOffset` toggle (+0, +50, +100) - Reusable `KAVContent` component shared between regular screen and modal No changes to the existing KAV example screen or E2E tests. ## Test Plan **iOS (iPhone 16 simulator), `automaticOffset={true}`, `keyboardVerticalOffset={0}`:** - ✅ Regular screen — all content visible above keyboard (padding, position modes) - ✅ Modal — all content visible above keyboard (padding, position modes) **`automaticOffset={false}` (default), `keyboardVerticalOffset={100}`:** - ✅ Regular screen — same behavior as before this PR - ✅ RN implementation — same behavior as before this PR Fixes #867 --------- Co-authored-by: thomasvo <thomas.vo@openspace.ai> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2df2c28 commit 66e6278

7 files changed

Lines changed: 263 additions & 8 deletions

File tree

docs/docs/api/components/keyboard-avoiding-view.mdx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ A boolean prop indicating whether `KeyboardAvoidingView` is enabled or disabled.
134134

135135
This is the distance between the top of the user screen and the react native view. This is particularly useful when there are fixed headers, navigation bars, or other UI elements at the top of the screen. Default is `0`.
136136

137+
By default the view uses parent-relative coordinates from `onLayout`, so you typically need to set this to the height of your navigation header (e.g., pass `useHeaderHeight()` from `@react-navigation/elements`).
138+
139+
If you enable [`automaticOffset`](#automaticoffset), the view automatically detects its position on screen, and `keyboardVerticalOffset` becomes purely **additive** extra space — you no longer need to compensate for navigation headers or modals.
140+
137141
import KeyboardVerticalOffset from "@site/src/components/KeyboardVerticalOffset";
138142

139143
<details>
@@ -163,7 +167,7 @@ const MyScreen = () => {
163167

164168
- **Custom Toolbars or Fixed Elements at the Top** - If your app has a fixed toolbar, status bar, or other UI elements at the top, you should offset accordingly.
165169

166-
- **Modal Screens with Different Layouts** - When using `KeyboardAvoidingView` inside a `Modal`, you may need to manually define the vertical offset to account for the modals positioning.
170+
- **Modal Screens with Different Layouts** - When using `KeyboardAvoidingView` inside a `Modal`, you may need to manually define the vertical offset to account for the modal's positioning. Alternatively, consider using [`automaticOffset`](#automaticoffset) which handles modals automatically.
167171

168172
Below shown a visual representation of `keyboardVerticalOffset`:
169173

@@ -172,15 +176,21 @@ Below shown a visual representation of `keyboardVerticalOffset`:
172176
:::warning Handling `StatusBar` height on Android with `useHeaderHeight`
173177
On `Android`, how you handle the `StatusBar` height depends on whether the `StatusBar` is **translucent** or **not**:
174178

175-
- **If the `StatusBar` is translucent**, `react-navigation` **automatically includes the `StatusBar` height** in `useHeaderHeight()`, along with safe-area padding. This behavior aligns with iOS, so you dont need to manually add the `StatusBar` height.
179+
- **If the `StatusBar` is translucent**, `react-navigation` **automatically includes the `StatusBar` height** in `useHeaderHeight()`, along with safe-area padding. This behavior aligns with iOS, so you don't need to manually add the `StatusBar` height.
176180
- **If the StatusBar is not translucent**, `useHeaderHeight()` does **not** include the `StatusBar` height. In this case, you need to add it manually:
177181

178182
```tsx
179183
const headerHeight = useHeaderHeight() + (StatusBar.currentHeight ?? 0);
180184
```
181185

182-
Since `StatusBar.currentHeight` is an **Android-only** property, using `?? 0` ensures it doesnt cause issues on iOS. This approach avoids the need for `Platform.OS` or `Platform.select` checks.
186+
Since `StatusBar.currentHeight` is an **Android-only** property, using `?? 0` ensures it doesn't cause issues on iOS. This approach avoids the need for `Platform.OS` or `Platform.select` checks.
183187

184188
:::
185189

186190
</details>
191+
192+
### `automaticOffset`
193+
194+
When `true`, the view automatically detects its position on screen, accounting for navigation headers, modals, and other layout offsets. This means `keyboardVerticalOffset` becomes purely additive extra space rather than compensation for unknown positioning.
195+
196+
Default is `false`.

docs/docs/guides/components-overview.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import KeyboardExtender from "../api/views/keyboard-extender/keyboard-extender.l
5656

5757
Use `KeyboardAvoidingView` when you need to prevent the keyboard from hiding important UI elements, especially `TextInput` components. It automatically adjusts its layout—by changing its height, position, or padding—when the keyboard appears. A key advantage over the standard React Native component is its focus on _consistent behavior and smoother animations_ across both iOS and Android, simplifying cross-platform development. It's ideal for general screens like forms or chat interfaces.
5858

59-
You can control how it adjusts using the `behavior` prop (`padding`, `height`, `position`, `translate-with-padding`), and remember to set `keyboardVerticalOffset` if your view is positioned below a header or navigation bar.
59+
You can control how it adjusts using the `behavior` prop (`padding`, `height`, `position`, `translate-with-padding`). Use `keyboardVerticalOffset` to account for navigation headers, or enable `automaticOffset` to have headers and modals handled automatically.
6060

6161
## [`KeyboardStickyView`](../api/components/keyboard-sticky-view)
6262

example/src/constants/screenNames.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export enum ScreenNames {
1515
NATIVE_STACK = "NATIVE_STACK",
1616
NATIVE = "NATIVE",
1717
KEYBOARD_AVOIDING_VIEW = "KEYBOARD_AVOIDING_VIEW",
18+
KEYBOARD_AVOIDING_VIEW_AUTOMATIC = "KEYBOARD_AVOIDING_VIEW_AUTOMATIC",
1819
ENABLED_DISABLED = "ENABLED_DISABLED",
1920
CLOSE = "CLOSE",
2021
FOCUSED_INPUT_HANDLERS = "FOCUSED_INPUT_HANDLERS",

example/src/navigation/ExamplesStack/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import InteractiveKeyboard from "../../screens/Examples/InteractiveKeyboard";
1414
import InteractiveKeyboardIOS from "../../screens/Examples/InteractiveKeyboardIOS";
1515
import KeyboardAnimation from "../../screens/Examples/KeyboardAnimation";
1616
import KeyboardAvoidingViewExample from "../../screens/Examples/KeyboardAvoidingView";
17+
import KeyboardAvoidingViewAutomaticExample from "../../screens/Examples/KeyboardAvoidingViewAutomatic";
1718
import KeyboardChatScrollViewPlayground from "../../screens/Examples/KeyboardChatScrollView";
1819
import KeyboardExtender from "../../screens/Examples/KeyboardExtender";
1920
import KeyboardSharedTransitionExample from "../../screens/Examples/KeyboardSharedTransitions";
@@ -44,6 +45,7 @@ export type ExamplesStackParamList = {
4445
[ScreenNames.INTERACTIVE_KEYBOARD_IOS]: undefined;
4546
[ScreenNames.NATIVE_STACK]: undefined;
4647
[ScreenNames.KEYBOARD_AVOIDING_VIEW]: undefined;
48+
[ScreenNames.KEYBOARD_AVOIDING_VIEW_AUTOMATIC]: undefined;
4749
[ScreenNames.ENABLED_DISABLED]: undefined;
4850
[ScreenNames.CLOSE]: undefined;
4951
[ScreenNames.FOCUSED_INPUT_HANDLERS]: undefined;
@@ -103,6 +105,9 @@ const options = {
103105
[ScreenNames.KEYBOARD_AVOIDING_VIEW]: {
104106
title: "KAV",
105107
},
108+
[ScreenNames.KEYBOARD_AVOIDING_VIEW_AUTOMATIC]: {
109+
title: "KAV Automatic",
110+
},
106111
[ScreenNames.ENABLED_DISABLED]: {
107112
title: "Enabled/disabled",
108113
},
@@ -222,6 +227,11 @@ const ExamplesStack = () => (
222227
name={ScreenNames.KEYBOARD_AVOIDING_VIEW}
223228
options={options[ScreenNames.KEYBOARD_AVOIDING_VIEW]}
224229
/>
230+
<Stack.Screen
231+
component={KeyboardAvoidingViewAutomaticExample}
232+
name={ScreenNames.KEYBOARD_AVOIDING_VIEW_AUTOMATIC}
233+
options={options[ScreenNames.KEYBOARD_AVOIDING_VIEW_AUTOMATIC]}
234+
/>
225235
<Stack.Screen
226236
component={EnabledDisabled}
227237
name={ScreenNames.ENABLED_DISABLED}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import React, { useState } from "react";
2+
import {
3+
Modal,
4+
StyleSheet,
5+
Text,
6+
TextInput,
7+
TouchableOpacity,
8+
View,
9+
} from "react-native";
10+
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
11+
12+
type Behavior = "padding" | "height" | "position";
13+
const behaviors: Behavior[] = ["padding", "height", "position"];
14+
15+
function KAVContent({
16+
behavior,
17+
keyboardVerticalOffset,
18+
automaticOffset,
19+
}: {
20+
behavior: Behavior;
21+
keyboardVerticalOffset: number;
22+
automaticOffset: boolean;
23+
}) {
24+
return (
25+
// @ts-expect-error discriminated union doesn't narrow with dynamic behavior
26+
<KeyboardAvoidingView
27+
automaticOffset={automaticOffset}
28+
behavior={behavior}
29+
contentContainerStyle={
30+
behavior === "position" ? styles.container : undefined
31+
}
32+
keyboardVerticalOffset={keyboardVerticalOffset}
33+
style={styles.content}
34+
>
35+
<View style={styles.inner}>
36+
<Text style={styles.heading}>Header</Text>
37+
<View style={styles.inputs}>
38+
<TextInput
39+
placeholder="Username"
40+
placeholderTextColor="#7C7C7C"
41+
style={styles.textInput}
42+
/>
43+
<TextInput
44+
secureTextEntry
45+
placeholder="Password"
46+
placeholderTextColor="#7C7C7C"
47+
style={styles.textInput}
48+
/>
49+
</View>
50+
<TouchableOpacity style={styles.button}>
51+
<Text style={styles.buttonText}>Submit</Text>
52+
</TouchableOpacity>
53+
</View>
54+
</KeyboardAvoidingView>
55+
);
56+
}
57+
58+
export default function KeyboardAvoidingViewAutomaticExample() {
59+
const [behavior, setBehavior] = useState<Behavior>(behaviors[0]);
60+
const [automaticOffset, setAutomaticOffset] = useState(true);
61+
const [showModal, setShowModal] = useState(false);
62+
const [offset, setOffset] = useState(0);
63+
const offsets = [0, 50, 100];
64+
65+
return (
66+
<>
67+
<View style={styles.settings}>
68+
<TouchableOpacity
69+
style={styles.settingsButton}
70+
onPress={() => {
71+
const index = behaviors.indexOf(behavior);
72+
73+
setBehavior(
74+
behaviors[index === behaviors.length - 1 ? 0 : index + 1],
75+
);
76+
}}
77+
>
78+
<Text style={styles.settingsText}>{behavior}</Text>
79+
</TouchableOpacity>
80+
<TouchableOpacity
81+
style={styles.settingsButton}
82+
onPress={() => {
83+
const index = offsets.indexOf(offset);
84+
85+
setOffset(offsets[index === offsets.length - 1 ? 0 : index + 1]);
86+
}}
87+
>
88+
<Text style={styles.settingsText}>+{offset}</Text>
89+
</TouchableOpacity>
90+
<TouchableOpacity
91+
style={styles.settingsButton}
92+
onPress={() => setAutomaticOffset((v) => !v)}
93+
>
94+
<Text style={styles.settingsText}>
95+
{automaticOffset ? "Auto" : "Manual"}
96+
</Text>
97+
</TouchableOpacity>
98+
<TouchableOpacity
99+
style={styles.settingsButton}
100+
onPress={() => setShowModal(true)}
101+
>
102+
<Text style={styles.settingsText}>Modal</Text>
103+
</TouchableOpacity>
104+
</View>
105+
<KAVContent
106+
automaticOffset={automaticOffset}
107+
behavior={behavior}
108+
keyboardVerticalOffset={offset}
109+
/>
110+
<Modal
111+
animationType="slide"
112+
presentationStyle="pageSheet"
113+
visible={showModal}
114+
onRequestClose={() => setShowModal(false)}
115+
>
116+
<View style={styles.modalHeader}>
117+
<TouchableOpacity onPress={() => setShowModal(false)}>
118+
<Text style={styles.closeButton}>Close</Text>
119+
</TouchableOpacity>
120+
<Text style={styles.modalTitle}>
121+
Modal ({automaticOffset ? "Auto" : "Manual"})
122+
</Text>
123+
</View>
124+
<KAVContent
125+
automaticOffset={automaticOffset}
126+
behavior={behavior}
127+
keyboardVerticalOffset={offset}
128+
/>
129+
</Modal>
130+
</>
131+
);
132+
}
133+
134+
const styles = StyleSheet.create({
135+
container: {
136+
flex: 1,
137+
},
138+
content: {
139+
flex: 1,
140+
},
141+
heading: {
142+
fontSize: 36,
143+
marginBottom: 48,
144+
fontWeight: "600",
145+
},
146+
inner: {
147+
padding: 24,
148+
flex: 1,
149+
justifyContent: "space-between",
150+
},
151+
inputs: {},
152+
textInput: {
153+
height: 44,
154+
borderColor: "#000000",
155+
borderWidth: 1,
156+
borderRadius: 10,
157+
marginBottom: 36,
158+
paddingLeft: 10,
159+
},
160+
button: {
161+
marginTop: 12,
162+
height: 44,
163+
borderRadius: 10,
164+
backgroundColor: "rgb(40, 64, 147)",
165+
justifyContent: "center",
166+
alignItems: "center",
167+
},
168+
buttonText: {
169+
fontWeight: "500",
170+
fontSize: 16,
171+
color: "white",
172+
},
173+
settings: {
174+
flexDirection: "row",
175+
gap: 8,
176+
padding: 8,
177+
},
178+
settingsButton: {
179+
paddingHorizontal: 12,
180+
paddingVertical: 8,
181+
borderRadius: 8,
182+
backgroundColor: "#E8E8E8",
183+
},
184+
settingsText: {
185+
fontSize: 14,
186+
fontWeight: "500",
187+
},
188+
modalHeader: {
189+
flexDirection: "row",
190+
alignItems: "center",
191+
padding: 16,
192+
},
193+
closeButton: {
194+
color: "#007AFF",
195+
fontSize: 16,
196+
},
197+
modalTitle: {
198+
fontSize: 16,
199+
fontWeight: "600",
200+
flex: 1,
201+
textAlign: "center",
202+
marginRight: 40,
203+
},
204+
});

example/src/screens/Examples/Main/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ export const examples: Example[] = [
8181
info: ScreenNames.KEYBOARD_AVOIDING_VIEW,
8282
icons: "😶",
8383
},
84+
{
85+
title: "KeyboardAvoidingView Automatic",
86+
testID: "keyboard_avoiding_view_automatic",
87+
info: ScreenNames.KEYBOARD_AVOIDING_VIEW_AUTOMATIC,
88+
icons: "📐",
89+
},
8490
{
8591
title: "Enabled/disabled",
8692
testID: "enabled_disabled",

0 commit comments

Comments
 (0)