From a5cdf932185004e6b3dfb7a62ce6f491b12e648c Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Wed, 1 Apr 2026 18:33:44 +0300 Subject: [PATCH 1/2] feat: add `mode` prop for `KeyboardAwareScrollView` --- docs/blog/2026-03-16-release-1-21/index.mdx | 23 +++++ .../components/keyboard-aware-scroll-view.mdx | 28 ++++++ .../components/keyboard-aware-scroll-view.mdx | 28 ++++++ .../KeyboardAwareScrollView/index.tsx | 93 +++++++++++++------ .../KeyboardAwareScrollView/types.ts | 11 +++ src/components/index.ts | 1 + src/index.ts | 1 + 7 files changed, 158 insertions(+), 27 deletions(-) diff --git a/docs/blog/2026-03-16-release-1-21/index.mdx b/docs/blog/2026-03-16-release-1-21/index.mdx index 9bbb5ebfd0..6035f428ab 100644 --- a/docs/blog/2026-03-16-release-1-21/index.mdx +++ b/docs/blog/2026-03-16-release-1-21/index.mdx @@ -288,6 +288,29 @@ Starting from `1.21.0`, `KeyboardAwareScrollView` uses `contentInset` on iOS and This is a behavioral improvement — your existing `KeyboardAwareScrollView` usage needs no changes. +### Opting back into layout-based spacing with `mode="layout"` + +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 a submit button should rise with the keyboard because it's held in place by `justifyContent: "space-between"`, the inset approach won't do that — it extends scroll space without touching layout. + +For these cases, `1.21.0` ships a new `mode` prop: + +```tsx + + + + +``` + +| `mode` | How space is created | Layout reflow | +| ---------------------- | ------------------------------------- | --------------------- | +| `"insets"` _(default)_ | `contentInset` / `ClippingScrollView` | ❌ none | +| `"layout"` | Spacer `View` appended as last child | ✅ flex redistributes | + +The default remains `"insets"` — no migration needed for the common case. + ## Other notable changes Beyond the headline features, this release includes a couple of smaller but important additions. diff --git a/docs/docs/api/components/keyboard-aware-scroll-view.mdx b/docs/docs/api/components/keyboard-aware-scroll-view.mdx index f2a4da4f53..3f21d20ab2 100644 --- a/docs/docs/api/components/keyboard-aware-scroll-view.mdx +++ b/docs/docs/api/components/keyboard-aware-scroll-view.mdx @@ -116,6 +116,34 @@ If you have _**sticky elements**_ above the keyboard and want to extend the keyb 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. ::: +### `mode` + +Controls how keyboard space is created at the bottom of the `ScrollView`. Default is `"insets"`. + +| Value | Behavior | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `"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.** | +| `"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. | + +:::tip When to use `mode="layout"`? +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. + +```tsx + + + + +``` + +::: + +:::note +`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. +::: + ## Integration with 3rd party components ### `FlatList`/`FlashList`/`SectionList` etc. diff --git a/docs/versioned_docs/version-1.21.0/api/components/keyboard-aware-scroll-view.mdx b/docs/versioned_docs/version-1.21.0/api/components/keyboard-aware-scroll-view.mdx index f2a4da4f53..3f21d20ab2 100644 --- a/docs/versioned_docs/version-1.21.0/api/components/keyboard-aware-scroll-view.mdx +++ b/docs/versioned_docs/version-1.21.0/api/components/keyboard-aware-scroll-view.mdx @@ -116,6 +116,34 @@ If you have _**sticky elements**_ above the keyboard and want to extend the keyb 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. ::: +### `mode` + +Controls how keyboard space is created at the bottom of the `ScrollView`. Default is `"insets"`. + +| Value | Behavior | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `"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.** | +| `"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. | + +:::tip When to use `mode="layout"`? +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. + +```tsx + + + + +``` + +::: + +:::note +`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. +::: + ## Integration with 3rd party components ### `FlatList`/`FlashList`/`SectionList` etc. diff --git a/src/components/KeyboardAwareScrollView/index.tsx b/src/components/KeyboardAwareScrollView/index.tsx index da5bf242a4..fbee50ed6b 100644 --- a/src/components/KeyboardAwareScrollView/index.tsx +++ b/src/components/KeyboardAwareScrollView/index.tsx @@ -12,6 +12,7 @@ import Reanimated, { scrollTo, useAnimatedReaction, useAnimatedRef, + useAnimatedStyle, useDerivedValue, useSharedValue, } from "react-native-reanimated"; @@ -104,6 +105,7 @@ const KeyboardAwareScrollView = forwardRef< disableScrollOnKeyboardHide = false, enabled = true, extraKeyboardSpace = 0, + mode = "insets", ScrollViewComponent = Reanimated.ScrollView, snapToOffsets, ...rest @@ -219,30 +221,39 @@ const KeyboardAwareScrollView = forwardRef< }, [bottomOffset, enabled, height, snapToOffsets], ); - const removeGhostPadding = useCallback((e: number) => { - "worklet"; + const removeGhostPadding = useCallback( + (e: number) => { + "worklet"; - // new `ScrollViewWithBottomPadding` behavior: if we hide keyboard and we are in the end of `ScrollView` - // then we always need to scroll back, because we apply a padding that doesn't change layout, so we will - // not have auto scroll back in this case - if (!keyboardWillAppear.value && ghostViewSpace.value > 0) { - scrollTo( - scrollViewAnimatedRef, - 0, - scrollPosition.value - - interpolate( - e, - [initialKeyboardSize.value, keyboardHeight.value], - [ghostViewSpace.value, 0], - ), - false, - ); + // layout mode: the spacer view participates in layout, so the ScrollView + // reflows naturally when it shrinks — no manual scroll correction needed. + if (mode === "layout") { + return false; + } - return true; - } + // insets mode: `ScrollViewWithBottomPadding` extends scrollable area without + // changing layout, so when the keyboard hides and we're at the end of the + // ScrollView we must manually scroll back. + if (!keyboardWillAppear.value && ghostViewSpace.value > 0) { + scrollTo( + scrollViewAnimatedRef, + 0, + scrollPosition.value - + interpolate( + e, + [initialKeyboardSize.value, keyboardHeight.value], + [ghostViewSpace.value, 0], + ), + false, + ); - return false; - }, []); + return true; + } + + return false; + }, + [mode], + ); const performScrollWithPositionRestoration = useCallback( (newPosition: number) => { "worklet"; @@ -445,13 +456,15 @@ const KeyboardAwareScrollView = forwardRef< } } - ghostViewSpace.value = - position.value + - scrollViewLayout.value.height - - scrollViewContentSize.value.height; + if (mode === "insets") { + ghostViewSpace.value = + position.value + + scrollViewLayout.value.height - + scrollViewContentSize.value.height; - if (ghostViewSpace.value > 0) { - scrollPosition.value = position.value; + if (ghostViewSpace.value > 0) { + scrollPosition.value = position.value; + } } }, onMove: (e) => { @@ -489,6 +502,7 @@ const KeyboardAwareScrollView = forwardRef< }, }, [ + mode, maybeScroll, removeGhostPadding, disableScrollOnKeyboardHide, @@ -555,6 +569,31 @@ const KeyboardAwareScrollView = forwardRef< () => (enabled ? currentKeyboardFrameHeight.value : 0), [enabled], ); + // layout mode only: a spacer view whose paddingBottom grows with the keyboard. + // The `+ 1` ensures the scroll view never reaches its absolute end during animation, + // avoiding the layout recalculation that triggers on every frame at the boundary. + // see: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/342 + const layoutSpacerStyle = useAnimatedStyle( + () => + enabled && mode === "layout" + ? { paddingBottom: currentKeyboardFrameHeight.value + 1 } + : {}, + [enabled, mode], + ); + + if (mode === "layout") { + return ( + + {children} + {enabled && } + + ); + } return ( Date: Fri, 3 Apr 2026 11:14:40 +0300 Subject: [PATCH 2/2] fix: performance regression for layout mode --- docs/blog/2026-03-16-release-1-21/index.mdx | 4 ++-- src/components/KeyboardAwareScrollView/index.tsx | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/blog/2026-03-16-release-1-21/index.mdx b/docs/blog/2026-03-16-release-1-21/index.mdx index 6035f428ab..4c65095555 100644 --- a/docs/blog/2026-03-16-release-1-21/index.mdx +++ b/docs/blog/2026-03-16-release-1-21/index.mdx @@ -290,7 +290,7 @@ This is a behavioral improvement — your existing `KeyboardAwareScrollView` usa ### Opting back into layout-based spacing with `mode="layout"` -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 a submit button should rise with the keyboard because it's held in place by `justifyContent: "space-between"`, the inset approach won't do that — it extends scroll space without touching layout. +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. For these cases, `1.21.0` ships a new `mode` prop: @@ -309,7 +309,7 @@ For these cases, `1.21.0` ships a new `mode` prop: | `"insets"` _(default)_ | `contentInset` / `ClippingScrollView` | ❌ none | | `"layout"` | Spacer `View` appended as last child | ✅ flex redistributes | -The default remains `"insets"` — no migration needed for the common case. +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"`. ## Other notable changes diff --git a/src/components/KeyboardAwareScrollView/index.tsx b/src/components/KeyboardAwareScrollView/index.tsx index fbee50ed6b..90586e84b3 100644 --- a/src/components/KeyboardAwareScrollView/index.tsx +++ b/src/components/KeyboardAwareScrollView/index.tsx @@ -414,8 +414,14 @@ const KeyboardAwareScrollView = forwardRef< scrollPosition.value = position.value; // just persist height - later will be used in interpolation keyboardHeight.value = e.height; - // and update keyboard spacer size - syncKeyboardFrame(e); + + // insets mode: set the full contentInset upfront so that maybeScroll + // calculations are correct from the very first onMove frame. + // layout mode: do NOT set it here — the spacer must grow frame-by-frame + // in onMove to avoid a premature full-height jump before the keyboard moves. + if (mode === "insets") { + syncKeyboardFrame(e); + } } // focus was changed @@ -474,6 +480,11 @@ const KeyboardAwareScrollView = forwardRef< return; } + // layout mode: drive the spacer view animation frame-by-frame + if (mode === "layout") { + syncKeyboardFrame(e); + } + // if the user has set disableScrollOnKeyboardHide, only auto-scroll when the keyboard opens if (!disableScrollOnKeyboardHide || keyboardWillAppear.value) { maybeScroll(e.height);