Skip to content

Commit fbedbc2

Browse files
authored
feat: add mode prop for KeyboardAwareScrollView (#1420)
## 📜 Description Added `mode` prop to `KeyboardAwareScrollView`. ## 💡 Motivation and Context It seems that increasing `height` of fake view in `KeyboardAwareScrollView` sometimes is a desired behavior. Even though I think like it's still better to avoid - obviously by performing such a significant refactor we introduce breaking changes and to simplify migration for other developers I added `mode` prop compatibility. I would like to dive deeper and see if we can use `translate` approach for such things to make performance better (i. e. sheets etc.), but this is a big research and for now I just want to give an ability to use old behavior 🤞 Closes #1414 Potentially #1368 ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### JS - add `mode` property to `KeyboardAwareScrollView`; ### Docs - mention in blogpost new behavior; - add new property description to docs page. ## 🤔 How Has This Been Tested? Tested via e2e tests in CI. ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 10c0218 commit fbedbc2

7 files changed

Lines changed: 171 additions & 29 deletions

File tree

docs/blog/2026-03-16-release-1-21/index.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,29 @@ Starting from `1.21.0`, `KeyboardAwareScrollView` uses `contentInset` on iOS and
288288

289289
This is a behavioral improvement — your existing `KeyboardAwareScrollView` usage needs no changes.
290290

291+
### Opting back into layout-based spacing with `mode="layout"`
292+
293+
There is, however, one class of UI where the old behavior was actually the _desired_ outcome: layouts that **intentionally rely on flex redistribution**. If you have a form where layout re-distribution helps to put form in correct position, the inset approach won't do that — it extends scroll space without touching layout.
294+
295+
For these cases, `1.21.0` ships a new `mode` prop:
296+
297+
```tsx
298+
<KeyboardAwareScrollView
299+
mode="layout"
300+
contentContainerStyle={{ flex: 1, justifyContent: "space-between" }}
301+
>
302+
<FormFields />
303+
<SubmitButton />
304+
</KeyboardAwareScrollView>
305+
```
306+
307+
| `mode` | How space is created | Layout reflow |
308+
| ---------------------- | ------------------------------------- | --------------------- |
309+
| `"insets"` _(default)_ | `contentInset` / `ClippingScrollView` | ❌ none |
310+
| `"layout"` | Spacer `View` appended as last child | ✅ flex redistributes |
311+
312+
I intentionally made `"insets"` default to deliver best possible performance, but if your layout depends on layout changes - you may want to use `mode="layout"`.
313+
291314
## Other notable changes
292315

293316
Beyond the headline features, this release includes a couple of smaller but important additions.

docs/docs/api/components/keyboard-aware-scroll-view.mdx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,34 @@ If you have _**sticky elements**_ above the keyboard and want to extend the keyb
116116
This property acts as [extraScrollHeight](https://github.com/APSL/react-native-keyboard-aware-scroll-view/tree/9eee405f7b3e261faf86a0fc8e495288d91c853e?tab=readme-ov-file#props) from original [react-native-keyboard-aware-scroll-view](https://github.com/APSL/react-native-keyboard-aware-scroll-view) package.
117117
:::
118118

119+
### `mode`
120+
121+
Controls how keyboard space is created at the bottom of the `ScrollView`. Default is `"insets"`.
122+
123+
| Value | Behavior |
124+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
125+
| `"insets"` | Extends the scrollable area via `contentInset` (iOS) and `ClippingScrollView` (Android). Content layout is never modified — no layout reflows occur during keyboard animation. **Recommended for most use cases.** |
126+
| `"layout"` | Appends a spacer `View` as the last child of the `ScrollView`. The spacer participates in layout, so flex-based arrangements (e.g. `justifyContent: "space-between"`, `gap`) reflow naturally when the keyboard appears. |
127+
128+
:::tip When to use `mode="layout"`?
129+
If your `ScrollView` content relies on flex distribution — for example a form where a submit button should stay visually pinned to the bottom of the available space — use `mode="layout"` so the layout engine redistributes space when the keyboard appears.
130+
131+
```tsx
132+
<KeyboardAwareScrollView
133+
mode="layout"
134+
contentContainerStyle={{ flex: 1, justifyContent: "space-between" }}
135+
>
136+
<FormFields />
137+
<SubmitButton />
138+
</KeyboardAwareScrollView>
139+
```
140+
141+
:::
142+
143+
:::note
144+
`mode="insets"` was introduced in `1.21.0` and is the default. `mode="layout"` restores the pre-`1.21.0` spacer-based behavior for cases where layout reflow is intentional.
145+
:::
146+
119147
## Integration with 3rd party components
120148

121149
### `FlatList`/`FlashList`/`SectionList` etc.

docs/versioned_docs/version-1.21.0/api/components/keyboard-aware-scroll-view.mdx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,34 @@ If you have _**sticky elements**_ above the keyboard and want to extend the keyb
116116
This property acts as [extraScrollHeight](https://github.com/APSL/react-native-keyboard-aware-scroll-view/tree/9eee405f7b3e261faf86a0fc8e495288d91c853e?tab=readme-ov-file#props) from original [react-native-keyboard-aware-scroll-view](https://github.com/APSL/react-native-keyboard-aware-scroll-view) package.
117117
:::
118118

119+
### `mode`
120+
121+
Controls how keyboard space is created at the bottom of the `ScrollView`. Default is `"insets"`.
122+
123+
| Value | Behavior |
124+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
125+
| `"insets"` | Extends the scrollable area via `contentInset` (iOS) and `ClippingScrollView` (Android). Content layout is never modified — no layout reflows occur during keyboard animation. **Recommended for most use cases.** |
126+
| `"layout"` | Appends a spacer `View` as the last child of the `ScrollView`. The spacer participates in layout, so flex-based arrangements (e.g. `justifyContent: "space-between"`, `gap`) reflow naturally when the keyboard appears. |
127+
128+
:::tip When to use `mode="layout"`?
129+
If your `ScrollView` content relies on flex distribution — for example a form where a submit button should stay visually pinned to the bottom of the available space — use `mode="layout"` so the layout engine redistributes space when the keyboard appears.
130+
131+
```tsx
132+
<KeyboardAwareScrollView
133+
mode="layout"
134+
contentContainerStyle={{ flex: 1, justifyContent: "space-between" }}
135+
>
136+
<FormFields />
137+
<SubmitButton />
138+
</KeyboardAwareScrollView>
139+
```
140+
141+
:::
142+
143+
:::note
144+
`mode="insets"` was introduced in `1.21.0` and is the default. `mode="layout"` restores the pre-`1.21.0` spacer-based behavior for cases where layout reflow is intentional.
145+
:::
146+
119147
## Integration with 3rd party components
120148

121149
### `FlatList`/`FlashList`/`SectionList` etc.

src/components/KeyboardAwareScrollView/index.tsx

Lines changed: 79 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Reanimated, {
1212
scrollTo,
1313
useAnimatedReaction,
1414
useAnimatedRef,
15+
useAnimatedStyle,
1516
useDerivedValue,
1617
useSharedValue,
1718
} from "react-native-reanimated";
@@ -104,6 +105,7 @@ const KeyboardAwareScrollView = forwardRef<
104105
disableScrollOnKeyboardHide = false,
105106
enabled = true,
106107
extraKeyboardSpace = 0,
108+
mode = "insets",
107109
ScrollViewComponent = Reanimated.ScrollView,
108110
snapToOffsets,
109111
...rest
@@ -219,30 +221,39 @@ const KeyboardAwareScrollView = forwardRef<
219221
},
220222
[bottomOffset, enabled, height, snapToOffsets],
221223
);
222-
const removeGhostPadding = useCallback((e: number) => {
223-
"worklet";
224+
const removeGhostPadding = useCallback(
225+
(e: number) => {
226+
"worklet";
224227

225-
// new `ScrollViewWithBottomPadding` behavior: if we hide keyboard and we are in the end of `ScrollView`
226-
// then we always need to scroll back, because we apply a padding that doesn't change layout, so we will
227-
// not have auto scroll back in this case
228-
if (!keyboardWillAppear.value && ghostViewSpace.value > 0) {
229-
scrollTo(
230-
scrollViewAnimatedRef,
231-
0,
232-
scrollPosition.value -
233-
interpolate(
234-
e,
235-
[initialKeyboardSize.value, keyboardHeight.value],
236-
[ghostViewSpace.value, 0],
237-
),
238-
false,
239-
);
228+
// layout mode: the spacer view participates in layout, so the ScrollView
229+
// reflows naturally when it shrinks — no manual scroll correction needed.
230+
if (mode === "layout") {
231+
return false;
232+
}
240233

241-
return true;
242-
}
234+
// insets mode: `ScrollViewWithBottomPadding` extends scrollable area without
235+
// changing layout, so when the keyboard hides and we're at the end of the
236+
// ScrollView we must manually scroll back.
237+
if (!keyboardWillAppear.value && ghostViewSpace.value > 0) {
238+
scrollTo(
239+
scrollViewAnimatedRef,
240+
0,
241+
scrollPosition.value -
242+
interpolate(
243+
e,
244+
[initialKeyboardSize.value, keyboardHeight.value],
245+
[ghostViewSpace.value, 0],
246+
),
247+
false,
248+
);
243249

244-
return false;
245-
}, []);
250+
return true;
251+
}
252+
253+
return false;
254+
},
255+
[mode],
256+
);
246257
const performScrollWithPositionRestoration = useCallback(
247258
(newPosition: number) => {
248259
"worklet";
@@ -403,8 +414,14 @@ const KeyboardAwareScrollView = forwardRef<
403414
scrollPosition.value = position.value;
404415
// just persist height - later will be used in interpolation
405416
keyboardHeight.value = e.height;
406-
// and update keyboard spacer size
407-
syncKeyboardFrame(e);
417+
418+
// insets mode: set the full contentInset upfront so that maybeScroll
419+
// calculations are correct from the very first onMove frame.
420+
// layout mode: do NOT set it here — the spacer must grow frame-by-frame
421+
// in onMove to avoid a premature full-height jump before the keyboard moves.
422+
if (mode === "insets") {
423+
syncKeyboardFrame(e);
424+
}
408425
}
409426

410427
// focus was changed
@@ -445,13 +462,15 @@ const KeyboardAwareScrollView = forwardRef<
445462
}
446463
}
447464

448-
ghostViewSpace.value =
449-
position.value +
450-
scrollViewLayout.value.height -
451-
scrollViewContentSize.value.height;
465+
if (mode === "insets") {
466+
ghostViewSpace.value =
467+
position.value +
468+
scrollViewLayout.value.height -
469+
scrollViewContentSize.value.height;
452470

453-
if (ghostViewSpace.value > 0) {
454-
scrollPosition.value = position.value;
471+
if (ghostViewSpace.value > 0) {
472+
scrollPosition.value = position.value;
473+
}
455474
}
456475
},
457476
onMove: (e) => {
@@ -461,6 +480,11 @@ const KeyboardAwareScrollView = forwardRef<
461480
return;
462481
}
463482

483+
// layout mode: drive the spacer view animation frame-by-frame
484+
if (mode === "layout") {
485+
syncKeyboardFrame(e);
486+
}
487+
464488
// if the user has set disableScrollOnKeyboardHide, only auto-scroll when the keyboard opens
465489
if (!disableScrollOnKeyboardHide || keyboardWillAppear.value) {
466490
maybeScroll(e.height);
@@ -489,6 +513,7 @@ const KeyboardAwareScrollView = forwardRef<
489513
},
490514
},
491515
[
516+
mode,
492517
maybeScroll,
493518
removeGhostPadding,
494519
disableScrollOnKeyboardHide,
@@ -555,6 +580,31 @@ const KeyboardAwareScrollView = forwardRef<
555580
() => (enabled ? currentKeyboardFrameHeight.value : 0),
556581
[enabled],
557582
);
583+
// layout mode only: a spacer view whose paddingBottom grows with the keyboard.
584+
// The `+ 1` ensures the scroll view never reaches its absolute end during animation,
585+
// avoiding the layout recalculation that triggers on every frame at the boundary.
586+
// see: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/342
587+
const layoutSpacerStyle = useAnimatedStyle(
588+
() =>
589+
enabled && mode === "layout"
590+
? { paddingBottom: currentKeyboardFrameHeight.value + 1 }
591+
: {},
592+
[enabled, mode],
593+
);
594+
595+
if (mode === "layout") {
596+
return (
597+
<ScrollViewComponent
598+
ref={onRef}
599+
{...rest}
600+
scrollEventThrottle={16}
601+
onLayout={onScrollViewLayout}
602+
>
603+
{children}
604+
{enabled && <Reanimated.View style={layoutSpacerStyle} />}
605+
</ScrollViewComponent>
606+
);
607+
}
558608

559609
return (
560610
<ScrollViewWithBottomPadding

src/components/KeyboardAwareScrollView/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { AnimatedScrollViewComponent } from "../ScrollViewWithBottomPadding";
22
import type { ScrollView, ScrollViewProps } from "react-native";
33

4+
export type KeyboardAwareScrollViewMode = "insets" | "layout";
5+
46
export type KeyboardAwareScrollViewProps = {
57
/** The distance between the keyboard and the caret inside a focused `TextInput` when the keyboard is shown. Default is `0`. */
68
bottomOffset?: number;
@@ -10,6 +12,15 @@ export type KeyboardAwareScrollViewProps = {
1012
enabled?: boolean;
1113
/** Adjusting the bottom spacing of KeyboardAwareScrollView. Default is `0`. */
1214
extraKeyboardSpace?: number;
15+
/**
16+
* Controls how keyboard space is created at the bottom of the `ScrollView`.
17+
*
18+
* - `"insets"` *(default)*: Extends the scrollable area via `contentInset` (iOS) and `ClippingScrollView` (Android). No layout reflow occurs — content positions remain stable during keyboard animation. Recommended for most use cases.
19+
* - `"layout"`: Appends a spacer `View` as the last child of the `ScrollView`. The spacer participates in layout, so flex-based arrangements (e.g. `justifyContent: "space-between"`, `gap`) reflow naturally when the keyboard appears. Use this when you need content to physically rearrange around the keyboard space.
20+
*
21+
* Default is `"insets"`.
22+
*/
23+
mode?: KeyboardAwareScrollViewMode;
1324
/** Custom component for `ScrollView`. Default is `ScrollView`. */
1425
ScrollViewComponent?: AnimatedScrollViewComponent;
1526
} & ScrollViewProps;

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { default as KeyboardChatScrollView } from "./KeyboardChatScrollView";
99
export type { KeyboardAvoidingViewProps } from "./KeyboardAvoidingView";
1010
export type { KeyboardStickyViewProps } from "./KeyboardStickyView";
1111
export type {
12+
KeyboardAwareScrollViewMode,
1213
KeyboardAwareScrollViewProps,
1314
KeyboardAwareScrollViewRef,
1415
} from "./KeyboardAwareScrollView/types";

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type {
2020
KeyboardChatScrollViewProps,
2121
KeyboardAvoidingViewProps,
2222
KeyboardStickyViewProps,
23+
KeyboardAwareScrollViewMode,
2324
KeyboardAwareScrollViewProps,
2425
KeyboardAwareScrollViewRef,
2526
KeyboardToolbarProps,

0 commit comments

Comments
 (0)