fix(scrollable): preserve scroll position when keyboard dismisses#2676
fix(scrollable): preserve scroll position when keyboard dismisses#2676ronnymajani wants to merge 1 commit into
Conversation
When the keyboard dismisses, the inner scroll jumps back to offset 0 even though the user had scrolled. During the keyboard-down animation, the sheet `position` is between `extendedPositionWithKeyboard` and `extendedPosition`, so `animatedSheetState` falls through to `OPENED` (it only reports `EXTENDED` when `position` exactly matches `extendedPosition`). `useScrollable` then returns `LOCKED`, and `handleOnScroll` forces `scrollTo(0, 0)` because `shouldLockInitialPosition` was cleared earlier when the sheet was `EXTENDED` at drag start. The existing unlock condition only covers `KEYBOARD_STATUS.SHOWN`. Extend it to also cover animations whose `source` is `ANIMATION_SOURCE.KEYBOARD`, which handles the dismiss path where the keyboard is already `HIDDEN` but the sheet is still animating.
|
Hi, I was able to see the issue, I can confirm that it's there. Here's a recording previewing it: Screen.Recording.2026-05-21.at.15.31.03.mov |
I've been patching the library until this gets merged upstream. (file attached below) It resolves both this issue and #2675 I just put it under "patchedDependencies": {
"@gorhom/bottom-sheet@5.2.14": "patches/@gorhom%2Fbottom-sheet@5.2.14.patch",
}Hope this helps anyway else who stumbles here Here's the patch file if you're interested @gorhom%2Fbottom-sheet@5.2.14.patch diff --git a/src/components/bottomSheet/BottomSheet.tsx b/src/components/bottomSheet/BottomSheet.tsx
index fc520337115ce7a2c7aacb0f81f16b9046ce2c89..7661ddf73d7e51169647861491782539c10f0ebf 100644
--- a/src/components/bottomSheet/BottomSheet.tsx
+++ b/src/components/bottomSheet/BottomSheet.tsx
@@ -6,6 +6,7 @@ import React, {
useEffect,
useImperativeHandle,
useMemo,
+ useState,
} from 'react';
import { Dimensions, Platform, StyleSheet } from 'react-native';
import { State } from 'react-native-gesture-handler';
@@ -38,6 +39,8 @@ import {
BottomSheetInternalProvider,
BottomSheetProvider,
} from '../../contexts';
+import { NativeScrollGestureContext } from '../../contexts/gesture';
+import type { Gesture as RNGHGesture } from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gesture';
import {
useAnimatedDetents,
useAnimatedKeyboard,
@@ -92,6 +95,39 @@ type BottomSheet = BottomSheetMethods;
const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
function BottomSheet(props, ref) {
+ /**
+ * Stores the inner scrollable's native gesture so the content pan can
+ * declare `simultaneousWithExternalGesture(nativeScroll)` bidirectionally.
+ * See `NativeScrollGestureContext` for the rationale (one-sided
+ * simultaneousness is insufficient on Android).
+ */
+ const [nativeScrollGesture, setNativeScrollGestureRaw] =
+ useState<RNGHGesture | null>(null);
+ /**
+ * Idempotent setter: once a non-null gesture is registered, subsequent
+ * non-null registrations are ignored. Without this guard the system
+ * loops infinitely — setting the gesture re-renders the pan with new
+ * simultaneousHandlers, which feeds a new `draggableGesture` into the
+ * scrollable via context, which causes the scrollable's `useMemo` to
+ * rebuild `scrollableGesture`, whose new identity re-fires the
+ * registration effect, and so on. The bidirectional relation only needs
+ * to be established once; further updates would only churn the gesture
+ * tree without changing semantics.
+ */
+ const setNativeScrollGesture = useCallback(
+ (gesture: RNGHGesture | null) => {
+ setNativeScrollGestureRaw((prev) => {
+ if (prev !== null && gesture !== null) return prev;
+ return gesture;
+ });
+ },
+ []
+ );
+ const nativeScrollGestureContextValue = useMemo(
+ () => ({ nativeScrollGesture, setNativeScrollGesture }),
+ [nativeScrollGesture, setNativeScrollGesture]
+ );
+
//#region extract props
const {
// animations configurations
@@ -1794,6 +1830,7 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
return (
<BottomSheetProvider value={externalContextVariables}>
<BottomSheetInternalProvider value={internalContextVariables}>
+ <NativeScrollGestureContext.Provider value={nativeScrollGestureContextValue}>
<BottomSheetGestureHandlersProvider
gestureEventsHandlersHook={gestureEventsHandlersHook}
>
@@ -1869,6 +1906,7 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
/> */}
</BottomSheetHostingContainer>
</BottomSheetGestureHandlersProvider>
+ </NativeScrollGestureContext.Provider>
</BottomSheetInternalProvider>
</BottomSheetProvider>
);
diff --git a/src/components/bottomSheetDraggableView/BottomSheetDraggableView.tsx b/src/components/bottomSheetDraggableView/BottomSheetDraggableView.tsx
index 923e4301dcd4a5371a16c0fb6a49b491239b7198..13c78ca5e21c3b9ab836fcafcdfb919f743ac8b5 100644
--- a/src/components/bottomSheetDraggableView/BottomSheetDraggableView.tsx
+++ b/src/components/bottomSheetDraggableView/BottomSheetDraggableView.tsx
@@ -1,7 +1,10 @@
-import React, { memo, useMemo } from 'react';
+import React, { memo, useContext, useMemo } from 'react';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
-import { BottomSheetDraggableContext } from '../../contexts/gesture';
+import {
+ BottomSheetDraggableContext,
+ NativeScrollGestureContext,
+} from '../../contexts/gesture';
import {
useBottomSheetGestureHandlers,
useBottomSheetInternal,
@@ -26,6 +29,16 @@ const BottomSheetDraggableViewComponent = ({
failOffsetY,
} = useBottomSheetInternal();
const { contentPanGestureHandler } = useBottomSheetGestureHandlers();
+ /**
+ * The inner scrollable component registers its native scroll gesture here so
+ * we can declare `simultaneousWithExternalGesture(nativeScroll)` on the pan,
+ * matching the scrollable side's own `simultaneousWithExternalGesture(pan)`
+ * declaration. Without this bidirectional declaration, the parent pan
+ * cancels the child native scroll on every drag on Android (one-sided
+ * simultaneousness is insufficient there).
+ */
+ const nativeScrollGestureCtx = useContext(NativeScrollGestureContext);
+ const nativeScrollGesture = nativeScrollGestureCtx?.nativeScrollGesture;
//#endregion
//#region variables
@@ -40,6 +53,10 @@ const BottomSheetDraggableViewComponent = ({
refs.push(refreshControlGestureRef);
}
+ if (nativeScrollGesture) {
+ refs.push(nativeScrollGesture);
+ }
+
if (_providedSimultaneousHandlers) {
if (Array.isArray(_providedSimultaneousHandlers)) {
refs.push(..._providedSimultaneousHandlers);
@@ -53,6 +70,7 @@ const BottomSheetDraggableViewComponent = ({
_providedSimultaneousHandlers,
nativeGestureRef,
refreshControlGestureRef,
+ nativeScrollGesture,
]);
const draggableGesture = useMemo(() => {
let gesture = Gesture.Pan()
diff --git a/src/components/bottomSheetScrollable/createBottomSheetScrollableComponent.tsx b/src/components/bottomSheetScrollable/createBottomSheetScrollableComponent.tsx
index 6a57f511ff52a732ace18a27a0370f695fd01b8e..cfc5ce9d3edd3c657dc157d0042b77d196843014 100644
--- a/src/components/bottomSheetScrollable/createBottomSheetScrollableComponent.tsx
+++ b/src/components/bottomSheetScrollable/createBottomSheetScrollableComponent.tsx
@@ -1,8 +1,10 @@
import React, {
forwardRef,
useContext,
+ useEffect,
useImperativeHandle,
useMemo,
+ useRef,
} from 'react';
import { Gesture } from 'react-native-gesture-handler';
import { useAnimatedProps } from 'react-native-reanimated';
@@ -11,7 +13,10 @@ import {
SCROLLABLE_STATUS,
type SCROLLABLE_TYPE,
} from '../../constants';
-import { BottomSheetDraggableContext } from '../../contexts/gesture';
+import {
+ BottomSheetDraggableContext,
+ NativeScrollGestureContext,
+} from '../../contexts/gesture';
import {
useBottomSheetContentContainerStyle,
useBottomSheetInternal,
@@ -55,6 +60,14 @@ export function createBottomSheetScrollableComponent<T, P>(
//#region hooks
const draggableGesture = useContext(BottomSheetDraggableContext);
+ /**
+ * Registers our native scroll gesture with the outer `BottomSheetDraggableView`,
+ * which adds it to its `simultaneousWithExternalGesture` array on the pan.
+ * This makes the simultaneousness bidirectional — required for RNGH on
+ * Android to coordinate the pan and the native scroll without one
+ * canceling the other.
+ */
+ const nativeScrollGestureCtx = useContext(NativeScrollGestureContext);
const { scrollableRef, scrollableContentOffsetY, scrollHandler } =
useScrollHandler(
scrollEventsHandlersHook,
@@ -95,6 +108,43 @@ export function createBottomSheetScrollableComponent<T, P>(
: undefined,
[draggableGesture]
);
+
+ /**
+ * Register this scrollable's native gesture with the outer
+ * `BottomSheetDraggableView` so it can add the gesture to its pan's
+ * `simultaneousWithExternalGesture` array, completing the bidirectional
+ * simultaneousness declaration. This triggers a re-render of the parent
+ * `BottomSheet` (state update), which recomputes the pan's `useMemo` and
+ * re-attaches its `GestureDetector` with the relation now in place. A
+ * ref-based approach would not work here because RNGH resolves the
+ * gesture object eagerly and would see `null` at registration time;
+ * subsequent ref mutations are not picked up.
+ *
+ * No cleanup on deps change: nulling the registration would re-render
+ * the pan without the scroll in `simultaneousHandlers`, which feeds a
+ * different `draggableGesture` back through context and rebuilds
+ * `scrollableGesture`, which re-fires this effect — an infinite loop.
+ * The parent setter is idempotent (first non-null registration wins),
+ * so subsequent calls during the same component lifetime are no-ops.
+ * Unmount cleanup is handled separately below.
+ */
+ useEffect(() => {
+ if (!nativeScrollGestureCtx || !scrollableGesture) return;
+ nativeScrollGestureCtx.setNativeScrollGesture(scrollableGesture);
+ }, [scrollableGesture, nativeScrollGestureCtx]);
+
+ /**
+ * Unmount-only cleanup. Captures the latest context in a ref so the
+ * cleanup callback always nulls the current registration when the
+ * scrollable fully unmounts (e.g. when the bottom sheet closes).
+ */
+ const nativeScrollGestureCtxRef = useRef(nativeScrollGestureCtx);
+ nativeScrollGestureCtxRef.current = nativeScrollGestureCtx;
+ useEffect(() => {
+ return () => {
+ nativeScrollGestureCtxRef.current?.setNativeScrollGesture(null);
+ };
+ }, []);
//#endregion
//#region callbacks
diff --git a/src/contexts/gesture.ts b/src/contexts/gesture.ts
index a6b2d217a0d31a771c312fa235ae0f5f45d8365a..a4dc67533f209f048df4661bfe7d44fc51b52924 100644
--- a/src/contexts/gesture.ts
+++ b/src/contexts/gesture.ts
@@ -11,3 +11,31 @@ export const BottomSheetGestureHandlersContext =
createContext<BottomSheetGestureHandlersContextType | null>(null);
export const BottomSheetDraggableContext = createContext<Gesture | null>(null);
+
+/**
+ * Allows the inner scrollable component (`createBottomSheetScrollableComponent`)
+ * to register its native scroll gesture with the outer `BottomSheetDraggableView`,
+ * so the content pan gesture can declare `simultaneousWithExternalGesture(nativeScroll)`
+ * — making the simultaneousness bidirectional.
+ *
+ * Without this, only the scroll side declares simultaneousness with the pan
+ * (`Gesture.Native().simultaneousWithExternalGesture(panGesture)`). On Android
+ * one-sided declaration is insufficient: the parent pan cancels the child native
+ * scroll on every drag (visible symptom: `onBeginDrag` fires multiple times in
+ * succession with zero `onScroll` events). Declaring on both sides resolves the
+ * conflict and lets RNGH coordinate them correctly.
+ *
+ * State (not ref) is required because `simultaneousWithExternalGesture` resolves
+ * at gesture-handler registration time. A ref whose `.current` is null at that
+ * moment results in no relation being established; subsequent ref mutations
+ * aren't picked up. Using state forces a re-render when the native gesture
+ * mounts, recomputing the pan's `simultaneousHandlers` array and re-attaching
+ * the GestureDetector with the relation now in place.
+ */
+export interface NativeScrollGestureContextType {
+ nativeScrollGesture: Gesture | null;
+ setNativeScrollGesture: (gesture: Gesture | null) => void;
+}
+
+export const NativeScrollGestureContext =
+ createContext<NativeScrollGestureContextType | null>(null);
diff --git a/src/hooks/useScrollable.ts b/src/hooks/useScrollable.ts
index 9c19051d146e3f90bfb639af53d7767f66582a98..a8bed746460f1260375f0a273743e032e2716a85 100644
--- a/src/hooks/useScrollable.ts
+++ b/src/hooks/useScrollable.ts
@@ -6,6 +6,7 @@ import {
useSharedValue,
} from 'react-native-reanimated';
import {
+ ANIMATION_SOURCE,
ANIMATION_STATUS,
KEYBOARD_STATUS,
SCROLLABLE_STATUS,
@@ -62,13 +63,24 @@ export const useScrollable = (
}
/**
- * if keyboard is shown and sheet is animating
- * then we do not lock the scrolling to not lose
- * current scrollable scroll position.
+ * If the sheet is animating because of the keyboard (in either
+ * direction — show or hide), keep the scrollable UNLOCKED so the
+ * scroll position is preserved across the transition. The previous
+ * check only covered keyboard show (`KEYBOARD_STATUS.SHOWN`); during
+ * keyboard dismiss the keyboard status reports `HIDDEN` while the
+ * sheet is still animating back from its keyboard-offset position to
+ * its rest position, and during that window `animatedSheetState`
+ * falls through to `OPENED` (since the position no longer exactly
+ * equals either the extended or the extended-with-keyboard position).
+ * Without this branch, `LOCKED` kicks in mid-animation and
+ * `handleOnScroll` forces `scrollTo(0, 0)` — jumping the user back
+ * to the top.
*/
+ const animationState = animatedAnimationState.get();
if (
- animatedKeyboardState.get().status === KEYBOARD_STATUS.SHOWN &&
- animatedAnimationState.get().status === ANIMATION_STATUS.RUNNING
+ animationState.status === ANIMATION_STATUS.RUNNING &&
+ (animatedKeyboardState.get().status === KEYBOARD_STATUS.SHOWN ||
+ animationState.source === ANIMATION_SOURCE.KEYBOARD)
) {
return SCROLLABLE_STATUS.UNLOCKED;
}
|
|
This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 10 days. |
Motivation
When a
BottomSheetcontains a scrollable and aTextInput, the following sequence fails:TextInputfurther down — keyboard appears, sheet animates up (interactive keyboard behavior).The user's scroll position is lost on every keyboard dismiss, which makes long forms inside a sheet effectively unusable.
Root cause
The relevant code in
useScrollable.ts:This unlocks the scrollable during keyboard-show animations so the scroll position is preserved while the sheet translates. But it only matches
KEYBOARD_STATUS.SHOWN. During the dismiss animation, the keyboard status has already flipped toHIDDEN, while the sheet is still animating back fromextendedPositionWithKeyboardtoextendedPosition. So this branch doesn't apply on the way down.During that dismiss window:
animatedPositionis betweenextendedPositionWithKeyboardandextendedPosition.animatedSheetState(inBottomSheet.tsx) checks foranimatedPosition.value === extendedPosition(exact equality) — false during animation.isInTemporaryPositionwas just flipped tofalseingetEvaluatedPosition.SHEET_STATE.OPENED.With sheet state
OPENEDand the keyboard branch not matching,useScrollablereturnsLOCKED.handleOnScroll(inuseScrollEventsHandlersDefault.ts) then firesscrollTo(scrollableRef, 0, lockPosition, false). Because the user started dragging while the sheet wasEXTENDED,shouldLockInitialPositionisfalse, solockPosition = 0— instant jump to top.The bug only manifests because
OPENEDis reached mid-animation. Once the animation completes andposition === extendedPositionagain,animatedSheetStateflips back toEXTENDEDand unlocks the scroll — too late, thescrollTo(0)has already fired.What this PR does
Extends the unlock condition to also cover keyboard-dismiss animations by checking the animation's
source:animationState.source === ANIMATION_SOURCE.KEYBOARDis true for both the show and dismiss animations triggered by the keyboard state machine, so it cleanly covers the missing case without weakening the existing one.Files changed
src/hooks/useScrollable.ts— extended unlock condition; added anANIMATION_SOURCEimport.No API changes, no type changes.
Verification
yarn typescript— cleanyarn lint --error-on-warnings src/hooks/useScrollable.ts— cleanBottomSheetScrollViewcontaining several inputs: scroll position is now preserved across keyboard show and dismiss. Behavior unchanged for non-keyboard animations and for sheets without keyboard interactions.Notes
This is a pure scroll-preservation fix and is independent of #2675 (which fixes a separate Android-only scroll-cancellation bug on first sheet open). Either can land independently.