diff --git a/FabricExample/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx b/FabricExample/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx index 7daa5b0daa..a00df6f348 100644 --- a/FabricExample/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx +++ b/FabricExample/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx @@ -15,14 +15,20 @@ import { invertedContentContainerStyle, } from "./styles"; +import type { SharedValue } from "react-native-reanimated"; + +type VirtualizedListScrollViewProps = ScrollViewProps & { + extraContentPadding?: SharedValue; +}; + export type VirtualizedListScrollViewRef = React.ElementRef< typeof KeyboardChatScrollView >; const VirtualizedListScrollView = forwardRef< VirtualizedListScrollViewRef, - ScrollViewProps ->(({ onLayout: onLayoutProp, ...props }, ref) => { + VirtualizedListScrollViewProps +>(({ onLayout: onLayoutProp, extraContentPadding, ...props }, ref) => { const [layoutPass, setLayoutPass] = useState(0); const { bottom } = useSafeAreaInsets(); const chatKitOffset = bottom - MARGIN; @@ -50,6 +56,7 @@ const VirtualizedListScrollView = forwardRef< : contentContainerStyle } contentInsetAdjustmentBehavior="never" + extraContentPadding={extraContentPadding} freeze={freeze} inverted={isInvertedSupported} keyboardDismissMode="interactive" diff --git a/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx b/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx index 7d06c737e1..e3fe639f71 100644 --- a/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx +++ b/FabricExample/src/screens/Examples/KeyboardChatScrollView/index.tsx @@ -19,6 +19,7 @@ import { KeyboardGestureArea, KeyboardStickyView, } from "react-native-keyboard-controller"; +import { useSharedValue } from "react-native-reanimated"; import { SafeAreaView, useSafeAreaInsets, @@ -49,6 +50,7 @@ function KeyboardChatScrollViewPlayground() { const textInputRef = useRef(null); const textRef = useRef(""); const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT); + const extraContentPadding = useSharedValue(0); const { inverted, messages, reversedMessages, addMessage, mode } = useChatConfigStore(); const { bottom } = useSafeAreaInsets(); @@ -58,9 +60,16 @@ function KeyboardChatScrollViewPlayground() { [bottom], ); - const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => { - setInputHeight(e.nativeEvent.layout.height); - }, []); + const onInputLayoutChanged = useCallback( + (e: LayoutChangeEvent) => { + const height = e.nativeEvent.layout.height; + + // eslint-disable-next-line react-compiler/react-compiler + extraContentPadding.value = Math.max(height - TEXT_INPUT_HEIGHT, 0); + setInputHeight(height); + }, + [extraContentPadding], + ); const onInput = useCallback((text: string) => { textRef.current = text; }, []); @@ -90,8 +99,13 @@ function KeyboardChatScrollViewPlayground() { }, [messages]); const memoList = useCallback( - (props: ScrollViewProps) => , - [], + (props: ScrollViewProps) => ( + + ), + [extraContentPadding], ); return ( @@ -124,7 +138,7 @@ function KeyboardChatScrollViewPlayground() { startRenderingFromBottom: inverted, }} renderItem={({ item }) => } - renderScrollComponent={VirtualizedListScrollView} + renderScrollComponent={memoList} /> )} {mode === "flat" && ( @@ -138,7 +152,10 @@ function KeyboardChatScrollViewPlayground() { /> )} {mode === "scroll" && ( - + {messages.map((message, index) => ( ))} diff --git a/docs/docs/api/components/keyboard-chat-scroll-view.mdx b/docs/docs/api/components/keyboard-chat-scroll-view.mdx index 3069cd0463..2c836bd52b 100644 --- a/docs/docs/api/components/keyboard-chat-scroll-view.mdx +++ b/docs/docs/api/components/keyboard-chat-scroll-view.mdx @@ -84,6 +84,23 @@ The distance between the bottom of the screen and the `ScrollView`. When the key This is useful when the input is not at the very bottom of the screen — for example, when the `ScrollView` sits above a **safe area** inset, **bottom tabs**, or **any other fixed-height element**. In that case, set `offset` to the height of the elements between the `ScrollView` and the bottom of the screen. +### `extraContentPadding` + +A [Reanimated `SharedValue`](https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/) representing additional padding introduced by an element **outside** the keyboard — for example, a growing multiline `TextInput` inside a `KeyboardStickyView`. + +When this value changes, `KeyboardChatScrollView` does two things: + +1. **Extends the scrollable range** — the extra amount is added to `contentInset`, keeping all content reachable. +2. **Adjusts the scroll position** — conditionally, based on `keyboardLiftBehavior`, so the bottom messages stay visible as the input grows. + +:::tip When to use it? +Use `extraContentPadding` whenever an element that is **not** the keyboard changes the amount of space the chat list has to work with. The most common case is a multiline text input that grows as the user types. +::: + +:::note +The value must be a `SharedValue` (from `useSharedValue`) — not a plain number — so that changes are tracked on the UI thread without triggering a React re-render. +::: + ## Usage with virtualized lists `KeyboardChatScrollView` doesn't ship with built-in wrappers for third-party virtualized list libraries, but since all of them (`FlatList`, `FlashList`, `LegendList`) accept a custom scroll component, integration is straightforward. diff --git a/docs/docs/guides/building-chat-app.mdx b/docs/docs/guides/building-chat-app.mdx index f1ffdd3be4..ba21957788 100644 --- a/docs/docs/guides/building-chat-app.mdx +++ b/docs/docs/guides/building-chat-app.mdx @@ -197,6 +197,69 @@ const onKeyboardPress = () => { When `freeze` is `true`, all keyboard-driven layout changes (padding, content offset, scroll position) are paused. +### Handling a growing multiline input + +If your composer has a `multiline` `TextInput` that grows as the user types, you need to tell `KeyboardChatScrollView` about the extra space it takes up — otherwise the component doesn't know the scrollable range has changed and the bottom messages can get clipped under the input. + +Pass a `SharedValue` via the `extraContentPadding` prop. Update it in the `TextInput`'s `onLayout` callback whenever the height changes: + +```tsx +import { useCallback } from "react"; +import { TextInput, type LayoutChangeEvent } from "react-native"; +import { useSharedValue, withTiming } from "react-native-reanimated"; + +const MIN_INPUT_HEIGHT = 36; + +function ChatScreen() { + const extraContentPadding = useSharedValue(0); + + const onInputLayout = useCallback( + (e: LayoutChangeEvent) => { + const height = e.nativeEvent.layout.height; + + extraContentPadding.value = withTiming( + Math.max(height - MIN_INPUT_HEIGHT, 0), + { duration: 250 }, + ); + }, + [extraContentPadding], + ); + + return ( + <> + + {/* ...messages... */} + + + + + + ); +} +``` + +The value represents the **delta** above the baseline input height. When the input is at its minimum height the value is `0`; each line of growth adds to it. The component uses this value to: + +1. Extend the scrollable range so the last messages aren't hidden under the taller input. +2. Scroll the list down (based on `keyboardLiftBehavior`) so the bottom messages stay visible. + +:::note Using with virtualized lists +When using `extraContentPadding` with `FlatList`, `FlashList`, or `LegendList`, pass the shared value through your wrapper component — both to `KeyboardChatScrollView` and to `useCallback` dependencies so the reference is stable: + +```tsx +const renderScrollComponent = useCallback( + (props: ScrollViewProps) => ( + + ), + [extraContentPadding], +); +``` + +::: + ## Using with virtualized lists For production chat apps you'll likely use a virtualized list (`FlatList`, `FlashList`, or `LegendList`) instead of a plain `ScrollView`. All of these accept a custom scroll component, making integration straightforward. @@ -312,6 +375,7 @@ import { TextInput, TouchableOpacity, View, + type LayoutChangeEvent, type ScrollViewProps, } from "react-native"; import { @@ -319,6 +383,7 @@ import { KeyboardGestureArea, KeyboardStickyView, } from "react-native-keyboard-controller"; +import { useSharedValue, withTiming } from "react-native-reanimated"; import { SafeAreaView, useSafeAreaInsets, @@ -355,10 +420,13 @@ function ChatScreen() { const textRef = useRef(""); const [messages, setMessages] = useState(INITIAL_MESSAGES); const { bottom } = useSafeAreaInsets(); + const extraContentPadding = useSharedValue(0); const renderScrollComponent = useCallback( - (props: ScrollViewProps) => , - [], + (props: ScrollViewProps) => ( + + ), + [extraContentPadding], ); const onSend = useCallback(() => { @@ -370,6 +438,16 @@ function ChatScreen() { textRef.current = ""; }, []); + const onInputLayout = useCallback( + (e: LayoutChangeEvent) => { + extraContentPadding.value = withTiming( + Math.max(e.nativeEvent.layout.height - INPUT_HEIGHT, 0), + { duration: 250 }, + ); + }, + [extraContentPadding], + ); + return ( (textRef.current = text)} + onLayout={onInputLayout} /> Send diff --git a/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx b/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx index 2eaede01ca..26b02d186c 100644 --- a/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx +++ b/example/src/screens/Examples/KeyboardChatScrollView/VirtualizedListScrollView.tsx @@ -15,14 +15,20 @@ import { invertedContentContainerStyle, } from "./styles"; +import type { SharedValue } from "react-native-reanimated"; + +type VirtualizedListScrollViewProps = ScrollViewProps & { + extraContentPadding?: SharedValue; +}; + export type VirtualizedListScrollViewRef = React.ElementRef< typeof KeyboardChatScrollView >; const VirtualizedListScrollView = forwardRef< VirtualizedListScrollViewRef, - ScrollViewProps ->(({ onLayout: onLayoutProp, ...props }, ref) => { + VirtualizedListScrollViewProps +>(({ onLayout: onLayoutProp, extraContentPadding, ...props }, ref) => { const [layoutPass, setLayoutPass] = useState(0); const { bottom } = useSafeAreaInsets(); const chatKitOffset = bottom - MARGIN; @@ -49,6 +55,7 @@ const VirtualizedListScrollView = forwardRef< inverted ? invertedContentContainerStyle : contentContainerStyle } contentInsetAdjustmentBehavior="never" + extraContentPadding={extraContentPadding} freeze={freeze} inverted={isInvertedSupported} keyboardDismissMode="interactive" diff --git a/example/src/screens/Examples/KeyboardChatScrollView/index.tsx b/example/src/screens/Examples/KeyboardChatScrollView/index.tsx index eda0c3ad32..6038b52ac3 100644 --- a/example/src/screens/Examples/KeyboardChatScrollView/index.tsx +++ b/example/src/screens/Examples/KeyboardChatScrollView/index.tsx @@ -19,6 +19,7 @@ import { KeyboardGestureArea, KeyboardStickyView, } from "react-native-keyboard-controller"; +import { useSharedValue, withTiming } from "react-native-reanimated"; import { SafeAreaView, useSafeAreaInsets, @@ -50,6 +51,7 @@ function KeyboardChatScrollViewPlayground() { const textInputRef = useRef(null); const textRef = useRef(""); const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT); + const extraContentPadding = useSharedValue(0); const { inverted, messages, reversedMessages, addMessage, mode } = useChatConfigStore(); const { bottom } = useSafeAreaInsets(); @@ -59,9 +61,19 @@ function KeyboardChatScrollViewPlayground() { [bottom], ); - const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => { - setInputHeight(e.nativeEvent.layout.height); - }, []); + const onInputLayoutChanged = useCallback( + (e: LayoutChangeEvent) => { + const height = e.nativeEvent.layout.height; + + // eslint-disable-next-line react-compiler/react-compiler + extraContentPadding.value = withTiming( + Math.max(height - TEXT_INPUT_HEIGHT, 0), + { duration: 250 }, + ); + setInputHeight(height); + }, + [extraContentPadding], + ); const onInput = useCallback((text: string) => { textRef.current = text; }, []); @@ -91,8 +103,13 @@ function KeyboardChatScrollViewPlayground() { }, [messages]); const memoList = useCallback( - (props: ScrollViewProps) => , - [], + (props: ScrollViewProps) => ( + + ), + [extraContentPadding], ); return ( @@ -124,7 +141,7 @@ function KeyboardChatScrollViewPlayground() { inverted={inverted} keyExtractor={(item) => item.text} renderItem={({ item }) => } - renderScrollComponent={VirtualizedListScrollView} + renderScrollComponent={memoList} /> )} {mode === "flat" && ( @@ -138,7 +155,10 @@ function KeyboardChatScrollViewPlayground() { /> )} {mode === "scroll" && ( - + {messages.map((message, index) => ( ))} diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx index 2bb85525de..405bdf292e 100644 --- a/src/components/KeyboardChatScrollView/index.tsx +++ b/src/components/KeyboardChatScrollView/index.tsx @@ -1,16 +1,25 @@ import React, { forwardRef, useCallback, useMemo } from "react"; import { StyleSheet } from "react-native"; -import { useAnimatedRef, useAnimatedStyle } from "react-native-reanimated"; +import { + makeMutable, + useAnimatedRef, + useAnimatedStyle, + useDerivedValue, + useSharedValue, +} from "react-native-reanimated"; import Reanimated from "react-native-reanimated"; import useCombinedRef from "../hooks/useCombinedRef"; import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; import { useChatKeyboard } from "./useChatKeyboard"; +import { useExtraContentPadding } from "./useExtraContentPadding"; import type { KeyboardChatScrollViewProps } from "./types"; import type { LayoutChangeEvent } from "react-native"; +const ZERO_CONTENT_PADDING = makeMutable(0); + const KeyboardChatScrollView = forwardRef< Reanimated.ScrollView, React.PropsWithChildren @@ -23,6 +32,7 @@ const KeyboardChatScrollView = forwardRef< keyboardLiftBehavior = "always", freeze = false, offset = 0, + extraContentPadding = ZERO_CONTENT_PADDING, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, ...rest @@ -31,11 +41,17 @@ const KeyboardChatScrollView = forwardRef< ) => { const scrollViewRef = useAnimatedRef(); const onRef = useCombinedRef(ref, scrollViewRef); + const defaultExtraContentPadding = useSharedValue(0); + const effectiveExtraContentPadding = + extraContentPadding ?? defaultExtraContentPadding; const { padding, currentHeight, contentOffsetY, + scroll, + layout, + size, onLayout: onLayoutInternal, onContentSizeChange: onContentSizeChangeInternal, } = useChatKeyboard(scrollViewRef, { @@ -43,8 +59,25 @@ const KeyboardChatScrollView = forwardRef< keyboardLiftBehavior, freeze, offset, + extraContentPadding: effectiveExtraContentPadding, + }); + + useExtraContentPadding({ + scrollViewRef, + extraContentPadding: effectiveExtraContentPadding, + keyboardPadding: padding, + scroll, + layout, + size, + inverted, + keyboardLiftBehavior, + freeze, }); + const totalPadding = useDerivedValue( + () => padding.value + effectiveExtraContentPadding.value, + ); + const onLayout = useCallback( (e: LayoutChangeEvent) => { onLayoutInternal(e); @@ -81,7 +114,7 @@ const KeyboardChatScrollView = forwardRef< ; } & ScrollViewProps; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/__fixtures__/testUtils.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/__fixtures__/testUtils.ts index bc5051bc21..77139b8ac8 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/__fixtures__/testUtils.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/__fixtures__/testUtils.ts @@ -2,6 +2,7 @@ import { renderHook } from "@testing-library/react-native"; import { useAnimatedRef } from "react-native-reanimated"; import type { useChatKeyboard } from ".."; +import type { SharedValue } from "react-native-reanimated"; import type Reanimated from "react-native-reanimated"; export type KeyboardEvent = { height: number; duration?: number }; @@ -71,12 +72,15 @@ export function setupBeforeEach() { type RenderOptions = Omit< Parameters[1], - "freeze" | "offset" + "freeze" | "offset" | "extraContentPadding" > & { freeze?: boolean; offset?: number; + extraContentPadding?: SharedValue; }; +const ZERO_CONTENT_PADDING = { value: 0 } as SharedValue; + /** * Create a render function that loads the hook from the given module path. * @@ -98,6 +102,8 @@ export function createRender(modulePath: string) { ...options, freeze: options.freeze ?? false, offset: options.offset ?? 0, + extraContentPadding: + options.extraContentPadding ?? ZERO_CONTENT_PADDING, }); }); }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/closing.android.spec.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/closing.android.spec.ts index d8626ca01e..1bb270f17d 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/closing.android.spec.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/closing.android.spec.ts @@ -205,4 +205,71 @@ describe("`useChatKeyboard` — Android closing behaviors", () => { // persistent block: effective(200) < currentShift(300), !wasAtEnd, closing → true expect(result.current.padding.value).toBe(200); }); + + it("never inverted: should scroll to 0 on close when WAS at end", () => { + // At end for inverted means offset near 0 + mockOffset.value = 0; + const { result } = render({ + inverted: true, + keyboardLiftBehavior: "never", + }); + + handlers.onStart({ height: KEYBOARD }); + handlers.onMove({ height: KEYBOARD }); + + // Keyboard closes — offsetBeforeScroll re-captured as current scroll (0) + handlers.onStart({ height: 0 }); + // effective=200 < padding=300, wasAtEnd=isScrollAtEnd(0, 800, 2000, true)=true + handlers.onMove({ height: 200 }); + + expect(result.current.padding.value).toBe(200); + expect(mockScrollTo).toHaveBeenLastCalledWith( + expect.anything(), + 0, + 0, + false, + ); + }); + + it("never non-inverted: should clamp position on close when NOT at end", () => { + // Not at end: 100 + 800 = 900 < 2000 - 20 + mockOffset.value = 100; + render({ inverted: false, keyboardLiftBehavior: "never" }); + + handlers.onStart({ height: KEYBOARD }); + + // Keyboard closes; offsetBeforeScroll = 100 - 300 = -200 + handlers.onStart({ height: 0 }); + // effective=200 < padding=300, wasAtEnd=isScrollAtEnd(-200+300=100, 800, 2000)=false + // → else branch → clampScrollIfNeeded(200); scroll=100 < maxScroll=1400 → no scrollTo + handlers.onMove({ height: 200 }); + + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("whenAtEnd inverted: should clamp scroll to maxScroll on close when position exceeds new range", () => { + // Small content so that old scroll (900) exceeds maxScroll after keyboard shrinks + mockSize.value = { width: 390, height: 1500 }; + // Not at end: isScrollAtEnd(900, 800, 1500, true) = (900 <= 20) = false + mockOffset.value = 900; + render({ inverted: true, keyboardLiftBehavior: "whenAtEnd" }); + + handlers.onStart({ height: KEYBOARD }); + handlers.onMove({ height: KEYBOARD }); + mockScrollTo.mockClear(); + + // Keyboard closes — offsetBeforeScroll re-captured as 900 + handlers.onStart({ height: 0 }); + // effective=50 < padding=300, !shouldShift(whenAtEnd, false)=true + // closing && 50<300 → clampScrollIfNeeded(50) + // maxScroll = 1500-800+50 = 750; scroll(900) > 750 → scrollTo(750) + handlers.onMove({ height: 50 }); + + expect(mockScrollTo).toHaveBeenLastCalledWith( + expect.anything(), + 0, + 750, + false, + ); + }); }); diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/helpers.spec.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/helpers.spec.ts index 42378e351d..7e6146fb77 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/helpers.spec.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/helpers.spec.ts @@ -187,4 +187,49 @@ describe("`computeIOSContentOffset` specification", () => { expect(computeIOSContentOffset(100, 300, 200, 800, true)).toBe(-200); }); }); + + describe("with extraContentPadding", () => { + describe("non-inverted", () => { + it("should not clamp a scroll position that is valid within the extended range", () => { + // The key regression: scroll pushed to 1550 by extraContentPadding=50 + // relativeScroll = 1550 - 300 = 1250 + // Without ecp: maxScroll = 2000-800+300 = 1500 → result = 1500 (wrong) + // With ecp=50: maxScroll = 2000-800+300+50 = 1550 → result = 1550 (correct) + expect(computeIOSContentOffset(1250, 300, 2000, 800, false, 50)).toBe( + 1550, + ); + }); + + it("should not affect result when scroll is below maxScroll", () => { + // 300 + 100 = 400, still below maxScroll of 1550 + expect(computeIOSContentOffset(100, 300, 2000, 800, false, 50)).toBe( + 400, + ); + }); + + it("should clamp to the extended maxScroll", () => { + // maxScroll = 1000-800+300+50 = 550; 300+400=700 → clamped to 550 + expect(computeIOSContentOffset(400, 300, 1000, 800, false, 50)).toBe( + 550, + ); + }); + }); + + describe("inverted", () => { + it("should extend the minimum bound by extraContentPadding", () => { + // relativeScroll=-100 → min(-100-300, maxScroll)=-400, clamped to -(300+50)=-350 + // Without ecp: clamped to -300; with ecp=50: clamped to -350 + expect(computeIOSContentOffset(-100, 300, 1000, 800, true, 50)).toBe( + -350, + ); + }); + + it("should clamp to extended minimum when relativeScroll is very negative", () => { + // relativeScroll=-500, keyboard=300, ecp=50 → -500-300=-800, clamped to -350 + expect(computeIOSContentOffset(-500, 300, 1000, 800, true, 50)).toBe( + -350, + ); + }); + }); + }); }); diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.ios.ecp.spec.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.ios.ecp.spec.ts new file mode 100644 index 0000000000..522ba53191 --- /dev/null +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/__tests__/index.ios.ecp.spec.ts @@ -0,0 +1,142 @@ +import { + type Handlers, + KEYBOARD, + createRender, + mockOffset, + mockSize, + setupBeforeEach, +} from "../__fixtures__/testUtils"; + +import type { SharedValue } from "react-native-reanimated"; + +const render = createRender("../index.ios"); + +let handlers: Handlers = { + onStart: jest.fn(), + onMove: jest.fn(), + onInteractive: jest.fn(), + onEnd: jest.fn(), +}; + +jest.mock("../../../../hooks", () => ({ + useKeyboardHandler: jest.fn((h: Handlers) => { + handlers = h; + }), + useResizeMode: jest.fn(), +})); + +jest.mock("../../../hooks/useScrollState", () => ({ + __esModule: true, + default: jest.fn(() => ({ + offset: mockOffset, + layout: { value: { width: 390, height: 800 } }, + size: mockSize, + })), +})); + +beforeEach(() => { + setupBeforeEach(); +}); + +// mockSize: { width: 390, height: 2000 } +// mockLayout: { width: 390, height: 800 } +// KEYBOARD = 300 +// base maxScroll (no keyboard, no extraContentPadding) = 2000 - 800 = 1200 +// maxScroll with keyboard = 2000 - 800 + 300 = 1500 +// maxScroll with keyboard + extraContentPadding=50 = 2000 - 800 + 300 + 50 = 1550 + +describe("`useChatKeyboard` — iOS extraContentPadding", () => { + describe("spurious onStart (keyboard height unchanged)", () => { + it("should not clamp contentOffsetY when extraContentPadding grew between two onStart calls", () => { + const extraContentPadding = { value: 0 } as SharedValue; + + mockOffset.value = 1200; + const { result } = render({ + inverted: false, + keyboardLiftBehavior: "always", + extraContentPadding, + }); + + // Step 1: keyboard opens, extraContentPadding=0 + handlers.onStart({ height: KEYBOARD }); + expect(result.current.contentOffsetY!.value).toBe(1500); + + // Step 2: text input grows → extraContentPadding=50, scroll pushed to 1550 via scrollTo + extraContentPadding.value = 50; + mockOffset.value = 1550; + + // Step 3: spurious onStart — same keyboard height, fired during input resize + handlers.onStart({ height: KEYBOARD }); + + // Must stay at 1550, not be clamped to 1500 (the no-extraContentPadding max) + expect(result.current.contentOffsetY!.value).toBe(1550); + }); + + it("should not clamp contentOffsetY for inverted list with extraContentPadding", () => { + // Inverted: extraContentPadding extends the minimum bound from -300 to -350 + const extraContentPadding = { value: 50 } as SharedValue; + + mockOffset.value = -KEYBOARD; + const { result } = render({ + inverted: true, + keyboardLiftBehavior: "always", + extraContentPadding, + }); + + handlers.onStart({ height: KEYBOARD }); + // Simulate scroll pushed further by extraContentPadding + mockOffset.value = -350; + + // Spurious onStart — same keyboard height + handlers.onStart({ height: KEYBOARD }); + + // Must stay at -350, not be clamped to -300 (the no-extraContentPadding min) + expect(result.current.contentOffsetY!.value).toBe(-350); + }); + }); + + describe("persistent: snap to end when keyboard shrinks", () => { + it("should include extraContentPadding in the snap-to-end position", () => { + const extraContentPadding = { value: 50 } as SharedValue; + + // Scroll at the end (with keyboard + extraContentPadding) + mockOffset.value = 1550; + const { result } = render({ + inverted: false, + keyboardLiftBehavior: "persistent", + extraContentPadding, + }); + + handlers.onStart({ height: KEYBOARD }); + + // Keyboard shrinks by 50px (e.g. QuickType bar disappears) + mockOffset.value = 1550; + handlers.onStart({ height: 250 }); + + // Snap-to-end = 2000 - 800 + 250 + 50 = 1500 + expect(result.current.contentOffsetY!.value).toBe(1500); + }); + }); + + describe("never: snap to end when keyboard closes", () => { + it("should include extraContentPadding in the snap-to-end position", () => { + const extraContentPadding = { value: 50 } as SharedValue; + + mockOffset.value = 100; + const { result } = render({ + inverted: false, + keyboardLiftBehavior: "never", + extraContentPadding, + }); + + handlers.onStart({ height: KEYBOARD }); + + // Scroll at the end (with keyboard + extraContentPadding) + mockOffset.value = 1550; + handlers.onStart({ height: 0 }); + + // Snap-to-end = 2000 - 800 + 0 + 50 = 1250 + expect(result.current.contentOffsetY!.value).toBe(1250); + }); + }); +}); diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts index 5fdb840647..316f5f16d9 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts @@ -6,7 +6,7 @@ const AT_END_THRESHOLD = 20; /** * Map the current keyboard height to an effective height that accounts for a - * fixed offset (e.g. bottom safe-area or tab-bar height). + * fixed offset (e.g. Bottom safe-area or tab-bar height).. * * @param height - Current keyboard height. * @param targetKeyboardHeight - Full target keyboard height (captured on keyboard open). @@ -133,6 +133,7 @@ export function clampedScrollTarget( * @param contentHeight - Total height of the scrollable content. * @param layoutHeight - Visible height of the scroll view. * @param inverted - Whether the list is inverted. + * @param extraContentPadding - Additional content padding beyond keyboard height, such as extra space from a growing text input. * @returns The absolute contentOffset.y to set. * @example * ```ts @@ -145,6 +146,7 @@ export function computeIOSContentOffset( contentHeight: number, layoutHeight: number, inverted: boolean, + extraContentPadding: number = 0, ): number { "worklet"; @@ -153,11 +155,14 @@ export function computeIOSContentOffset( return Math.max( Math.min(relativeScroll - keyboardHeight, maxScroll), - -keyboardHeight, + -(keyboardHeight + extraContentPadding), ); } - const maxScroll = Math.max(contentHeight - layoutHeight + keyboardHeight, 0); + const maxScroll = Math.max( + contentHeight - layoutHeight + keyboardHeight + extraContentPadding, + 0, + ); return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); } diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts index 06fa48736c..c2fe483c01 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts @@ -34,7 +34,13 @@ function useChatKeyboard( scrollViewRef: AnimatedRef, options: UseChatKeyboardOptions, ): UseChatKeyboardReturn { - const { inverted, keyboardLiftBehavior, freeze, offset } = options; + const { + inverted, + keyboardLiftBehavior, + freeze, + offset, + extraContentPadding, + } = options; const padding = useSharedValue(0); const currentHeight = useSharedValue(0); @@ -85,10 +91,13 @@ function useChatKeyboard( if (atEnd) { if (inverted) { - contentOffsetY.value = -effective; + contentOffsetY.value = -(effective + extraContentPadding.value); } else { contentOffsetY.value = Math.max( - size.value.height - layout.value.height + effective, + size.value.height - + layout.value.height + + effective + + extraContentPadding.value, 0, ); } @@ -111,10 +120,13 @@ function useChatKeyboard( padding.value = effective; if (inverted) { - contentOffsetY.value = -effective; + contentOffsetY.value = -(effective + extraContentPadding.value); } else { contentOffsetY.value = Math.max( - size.value.height - layout.value.height + effective, + size.value.height - + layout.value.height + + effective + + extraContentPadding.value, 0, ); } @@ -142,6 +154,7 @@ function useChatKeyboard( size.value.height, layout.value.height, inverted, + extraContentPadding.value, ); }, onMove: () => { @@ -165,13 +178,16 @@ function useChatKeyboard( padding.value = effective; }, }, - [inverted, keyboardLiftBehavior, freeze, offset], + [inverted, keyboardLiftBehavior, freeze, offset, extraContentPadding], ); return { padding, currentHeight, contentOffsetY, + scroll, + layout, + size, onLayout, onContentSizeChange, }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts index b3e2d89c34..015193e9a6 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts @@ -24,10 +24,7 @@ import type Reanimated from "react-native-reanimated"; * @returns Shared values for padding and contentOffsetY (always `undefined`). * @example * ```tsx - * const { padding, contentOffsetY } = useChatKeyboard(ref, { - * inverted: false, - * keyboardLiftBehavior: "always", - * }); + * const { padding } = useChatKeyboard(ref, { inverted: false, keyboardLiftBehavior: "always" }); * ``` */ function useChatKeyboard( @@ -41,7 +38,6 @@ function useChatKeyboard( const offsetBeforeScroll = useSharedValue(0); const targetKeyboardHeight = useSharedValue(0); const closing = useSharedValue(false); - const { layout, size, @@ -49,7 +45,6 @@ function useChatKeyboard( onLayout, onContentSizeChange, } = useScrollState(scrollViewRef); - const clampScrollIfNeeded = (effective: number) => { "worklet"; @@ -292,6 +287,9 @@ function useChatKeyboard( padding, currentHeight, contentOffsetY: undefined, + scroll, + layout, + size, onLayout, onContentSizeChange, }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts index a4c9ca527a..91406dc7a8 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts @@ -8,6 +8,8 @@ type UseChatKeyboardOptions = { keyboardLiftBehavior: KeyboardLiftBehavior; freeze: boolean; offset: number; + /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ + extraContentPadding: SharedValue; }; type UseChatKeyboardReturn = { @@ -17,6 +19,12 @@ type UseChatKeyboardReturn = { currentHeight: SharedValue; /** Absolute Y content offset for iOS (set once in onStart). `undefined` on Android. */ contentOffsetY: SharedValue | undefined; + /** Current vertical scroll offset. */ + scroll: SharedValue; + /** Visible viewport dimensions. */ + layout: SharedValue<{ width: number; height: number }>; + /** Total content dimensions. */ + size: SharedValue<{ width: number; height: number }>; /** Callback to attach to ScrollView's onLayout prop to capture initial viewport dimensions. */ onLayout: (e: LayoutChangeEvent) => void; /** Callback to attach to ScrollView's onContentSizeChange prop to capture initial content dimensions. */ diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__fixtures__/setup.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__fixtures__/setup.ts new file mode 100644 index 0000000000..48577c84f9 --- /dev/null +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__fixtures__/setup.ts @@ -0,0 +1,50 @@ +import { renderHook } from "@testing-library/react-native"; +import { useAnimatedRef } from "react-native-reanimated"; + +import type { useExtraContentPadding } from ".."; +import type { SharedValue } from "react-native-reanimated"; +import type Reanimated from "react-native-reanimated"; + +export const mockScrollTo = jest.fn(); +export let reactionEffect: (current: number, previous: number | null) => void; + +jest.mock("react-native-reanimated", () => ({ + ...require("react-native-reanimated/mock"), + scrollTo: (...args: unknown[]) => mockScrollTo(...args), + useAnimatedReaction: ( + producer: () => number, + effect: (current: number, previous: number | null) => void, + ) => { + producer(); + reactionEffect = effect; + }, +})); + +export const sv = (initial: T): SharedValue => { + return { value: initial } as SharedValue; +}; + +type RenderOptions = Omit< + Parameters[0], + "scrollViewRef" +>; + +export const createRender = () => { + return function render(options: RenderOptions) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require("..") as { + useExtraContentPadding: typeof useExtraContentPadding; + }; + + return renderHook(() => { + const ref = useAnimatedRef(); + + mod.useExtraContentPadding({ scrollViewRef: ref, ...options }); + }); + }; +}; + +beforeEach(() => { + jest.resetModules(); + mockScrollTo.mockClear(); +}); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/always.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/always.spec.ts new file mode 100644 index 0000000000..c8b3e577e4 --- /dev/null +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/always.spec.ts @@ -0,0 +1,94 @@ +import { + createRender, + mockScrollTo, + reactionEffect, + sv, +} from "../__fixtures__/setup"; + +describe("useExtraContentPadding — always behavior", () => { + it("should scrollTo on grow when at end (non-inverted)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(300), + scroll: sv(1200), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(mockScrollTo).toHaveBeenCalledWith( + expect.anything(), + 0, + 1220, + false, + ); + }); + + it("should scrollTo on grow when NOT at end (non-inverted)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(300), + scroll: sv(100), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 120, false); + }); + + it("should scrollTo on shrink (non-inverted)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(0), + keyboardPadding: sv(300), + scroll: sv(1220), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + + reactionEffect(0, 20); + + expect(mockScrollTo).toHaveBeenCalledWith( + expect.anything(), + 0, + 1200, + false, + ); + }); + + it("should scrollTo on grow (inverted)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(300), + scroll: sv(5), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: true, + keyboardLiftBehavior: "always", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, -15, false); + }); +}); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/edge-cases.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/edge-cases.spec.ts new file mode 100644 index 0000000000..209c7d2957 --- /dev/null +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/edge-cases.spec.ts @@ -0,0 +1,115 @@ +import { + createRender, + mockScrollTo, + reactionEffect, + sv, +} from "../__fixtures__/setup"; + +describe("useExtraContentPadding — edge cases", () => { + it("should not scrollTo when delta is 0", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(0), + scroll: sv(100), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + + reactionEffect(20, 20); + + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("should not scrollTo on first render (previous is null)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(0), + scroll: sv(100), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + + reactionEffect(20, null); + + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("should not scrollTo when freeze is true", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(0), + scroll: sv(1200), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "always", + freeze: true, + }); + + reactionEffect(20, 0); + + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("should clamp to maxScroll (non-inverted)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(50), + keyboardPadding: sv(300), + scroll: sv(1490), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "always", + freeze: false, + }); + + // delta = 50, scroll + delta = 1540, maxScroll = 2000 - 800 + 300 + 50 = 1550 + reactionEffect(50, 0); + + expect(mockScrollTo).toHaveBeenCalledWith( + expect.anything(), + 0, + 1540, + false, + ); + }); + + it("should clamp to -totalPadding (inverted)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(50), + keyboardPadding: sv(300), + scroll: sv(-280), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: true, + keyboardLiftBehavior: "always", + freeze: false, + }); + + // delta = 50, target = -280 - 50 = -330, clamp to -350 + reactionEffect(50, 0); + + expect(mockScrollTo).toHaveBeenCalledWith( + expect.anything(), + 0, + -330, + false, + ); + }); +}); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/never.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/never.spec.ts new file mode 100644 index 0000000000..39068e2038 --- /dev/null +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/never.spec.ts @@ -0,0 +1,27 @@ +import { + createRender, + mockScrollTo, + reactionEffect, + sv, +} from "../__fixtures__/setup"; + +describe("useExtraContentPadding — never behavior", () => { + it("should NOT scrollTo on grow", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(0), + scroll: sv(1200), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "never", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(mockScrollTo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/persistent.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/persistent.spec.ts new file mode 100644 index 0000000000..0daa32322f --- /dev/null +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/persistent.spec.ts @@ -0,0 +1,65 @@ +import { + createRender, + mockScrollTo, + reactionEffect, + sv, +} from "../__fixtures__/setup"; + +describe("useExtraContentPadding — persistent behavior", () => { + it("should scrollTo on grow", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(300), + scroll: sv(100), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "persistent", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 120, false); + }); + + it("should NOT scrollTo on shrink when NOT at end", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(0), + keyboardPadding: sv(300), + scroll: sv(100), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "persistent", + freeze: false, + }); + + reactionEffect(0, 20); + + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("should scrollTo on shrink when at end", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(0), + keyboardPadding: sv(300), + scroll: sv(1200), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "persistent", + freeze: false, + }); + + reactionEffect(0, 20); + + expect(mockScrollTo).toHaveBeenCalled(); + }); +}); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/whenAtEnd.spec.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/whenAtEnd.spec.ts new file mode 100644 index 0000000000..80ef731b0f --- /dev/null +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/__tests__/whenAtEnd.spec.ts @@ -0,0 +1,84 @@ +import { + createRender, + mockScrollTo, + reactionEffect, + sv, +} from "../__fixtures__/setup"; + +describe("useExtraContentPadding — whenAtEnd behavior", () => { + it("should scrollTo when at end (non-inverted)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(0), + scroll: sv(1200), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "whenAtEnd", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(mockScrollTo).toHaveBeenCalled(); + }); + + it("should NOT scrollTo when NOT at end (non-inverted)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(0), + scroll: sv(100), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: false, + keyboardLiftBehavior: "whenAtEnd", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it("should scrollTo when at end (inverted)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(0), + scroll: sv(5), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: true, + keyboardLiftBehavior: "whenAtEnd", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(mockScrollTo).toHaveBeenCalled(); + }); + + it("should NOT scrollTo when NOT at end (inverted)", () => { + const render = createRender(); + + render({ + extraContentPadding: sv(20), + keyboardPadding: sv(0), + scroll: sv(100), + layout: sv({ width: 390, height: 800 }), + size: sv({ width: 390, height: 2000 }), + inverted: true, + keyboardLiftBehavior: "whenAtEnd", + freeze: false, + }); + + reactionEffect(20, 0); + + expect(mockScrollTo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts new file mode 100644 index 0000000000..3fcc99682a --- /dev/null +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts @@ -0,0 +1,102 @@ +import { scrollTo, useAnimatedReaction } from "react-native-reanimated"; + +import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers"; + +import type { KeyboardLiftBehavior } from "../useChatKeyboard/types"; +import type { AnimatedRef, SharedValue } from "react-native-reanimated"; +import type Reanimated from "react-native-reanimated"; + +type UseExtraContentPaddingOptions = { + scrollViewRef: AnimatedRef; + extraContentPadding: SharedValue; + /** Keyboard-only padding from useChatKeyboard — used to compute total padding for clamping. */ + keyboardPadding: SharedValue; + /** Current vertical scroll offset. */ + scroll: SharedValue; + /** Visible viewport dimensions. */ + layout: SharedValue<{ width: number; height: number }>; + /** Total content dimensions. */ + size: SharedValue<{ width: number; height: number }>; + inverted: boolean; + keyboardLiftBehavior: KeyboardLiftBehavior; + freeze: boolean; +}; + +/** + * Hook that reacts to `extraContentPadding` changes and conditionally + * adjusts the scroll position using `scrollTo` on both iOS and Android. + * + * Padding extension (scrollable range) is handled externally via a + * `useDerivedValue` that sums keyboard padding + extra content padding. + * This hook only handles the scroll correction. + * + * @param options - Configuration and shared values. + * @example + * ```tsx + * useExtraContentPadding({ scrollViewRef, extraContentPadding, ... }); + * ``` + */ +function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + const { + scrollViewRef, + extraContentPadding, + keyboardPadding, + scroll, + layout, + size, + inverted, + keyboardLiftBehavior, + freeze, + } = options; + + useAnimatedReaction( + () => extraContentPadding.value, + (current, previous) => { + if (freeze || previous === null) { + return; + } + + const delta = current - previous; + + if (delta === 0) { + return; + } + + const totalPadding = keyboardPadding.value + current; + + const atEnd = isScrollAtEnd( + scroll.value, + layout.value.height, + size.value.height, + inverted, + ); + + // "persistent": scroll on grow, hold position on shrink (unless at end) + if (keyboardLiftBehavior === "persistent" && delta < 0 && !atEnd) { + return; + } + + if (!shouldShiftContent(keyboardLiftBehavior, atEnd)) { + return; + } + + if (inverted) { + const target = Math.max(scroll.value - delta, -totalPadding); + + scrollTo(scrollViewRef, 0, target, false); + } else { + const maxScroll = Math.max( + size.value.height - layout.value.height + totalPadding, + 0, + ); + const target = Math.min(scroll.value + delta, maxScroll); + + scrollTo(scrollViewRef, 0, target, false); + } + }, + [inverted, keyboardLiftBehavior, freeze], + ); +} + +export { useExtraContentPadding }; +export type { UseExtraContentPaddingOptions }; diff --git a/src/components/ScrollViewWithBottomPadding/index.tsx b/src/components/ScrollViewWithBottomPadding/index.tsx index 79132cf967..4fd25d0452 100644 --- a/src/components/ScrollViewWithBottomPadding/index.tsx +++ b/src/components/ScrollViewWithBottomPadding/index.tsx @@ -1,6 +1,9 @@ import React, { forwardRef } from "react"; import { Platform, View } from "react-native"; -import Reanimated, { useAnimatedProps } from "react-native-reanimated"; +import Reanimated, { + useAnimatedProps, + useSharedValue, +} from "react-native-reanimated"; import { ClippingScrollView } from "../../bindings"; @@ -49,6 +52,8 @@ const ScrollViewWithBottomPadding = forwardRef< }, ref, ) => { + const prevContentOffsetY = useSharedValue(null); + const animatedProps = useAnimatedProps(() => { const insetTop = inverted ? bottomPadding.value : 0; const insetBottom = !inverted ? bottomPadding.value : 0; @@ -75,7 +80,13 @@ const ScrollViewWithBottomPadding = forwardRef< }; if (contentOffsetY) { - result.contentOffset = { x: 0, y: contentOffsetY.value }; + const curr = contentOffsetY.value; + + if (curr !== prevContentOffsetY.value) { + // eslint-disable-next-line react-compiler/react-compiler + prevContentOffsetY.value = curr; + result.contentOffset = { x: 0, y: curr }; + } } return result;