Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ export const KeyboardChatLegendList = typedForwardRef(
const refLegendList = useRef<LegendListRef | null>(null);
const combinedRef = useCombinedRef(forwardedRef, refLegendList);

const minimumContentPadding = useSharedValue<number>(0);
const blankSpace = useSharedValue<number>(0);

const calculateTopItemInset = useCallback(() => {
if (anchorToTopIndex === undefined || anchorToTopIndex < 0) {
minimumContentPadding.value = 0;
blankSpace.value = 0;
refLegendList.current?.reportContentInset(null);

return;
Expand Down Expand Up @@ -79,7 +79,7 @@ export const KeyboardChatLegendList = typedForwardRef(
state.scrollLength - contentBelowTopItem,
);

minimumContentPadding.value = calculatedInset;
blankSpace.value = calculatedInset;
refLegendList.current?.reportContentInset({ bottom: calculatedInset });
}, [anchorToTopIndex]);

Expand Down Expand Up @@ -121,12 +121,12 @@ export const KeyboardChatLegendList = typedForwardRef(
<KeyboardChatScrollView
{...scrollProps}
applyWorkaroundForContentInsetHitTestBug
blankSpace={blankSpace}
extraContentPadding={extraContentPadding}
minimumContentPadding={minimumContentPadding}
/>
);
},
[minimumContentPadding, extraContentPadding],
[blankSpace, extraContentPadding],
);

return (
Expand Down
16 changes: 8 additions & 8 deletions docs/docs/api/components/keyboard-chat-scroll-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,46 +113,46 @@ This uses Objective-C runtime method swizzling on the ScrollView's container vie
This prop uses runtime method swizzling, which can be fragile and may conflict with other libraries or future React Native versions. Use with caution and thoroughly test your app when enabling this workaround.
:::

### `minimumContentPadding`
### `blankSpace`

A [Reanimated `SharedValue<number>`](https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/) representing a minimum inset floor for the bottom padding.

When set, the total bottom padding is computed as:

```js
max(minimumContentPadding, keyboardPadding + extraContentPadding);
max(blankSpace, keyboardPadding + extraContentPadding);
```

This means the keyboard "absorbs" into the minimum padding rather than adding to it:

#### When `minimumContentPadding >= keyboard + extraContentPadding`
#### When `blankSpace >= keyboard + extraContentPadding`

Content does **not** move when the keyboard opens or closes — the minimum padding is large enough to absorb the keyboard height.

<Video
src="/video/keyboard-chat-scroll-view/minimumContentPaddingGreaterThan.mp4"
src="/video/keyboard-chat-scroll-view/blankSpaceGreaterThan.mp4"
style={{ height: "40vh" }}
/>

#### When `minimumContentPadding < keyboard + extraContentPadding`
#### When `blankSpace < keyboard + extraContentPadding`

Content moves, but only by the **excess amount** beyond the minimum floor.

<Video
src="/video/keyboard-chat-scroll-view/minimumContentPaddingLessThan.mp4"
src="/video/keyboard-chat-scroll-view/blankSpaceLessThan.mp4"
style={{ height: "40vh" }}
/>

:::tip When to use it?
Use `minimumContentPadding` in AI chat applications where a sent message needs space below it to push it to the top of the viewport while the AI response streams in. The minimum padding ensures this space remains available without causing additional movement when the keyboard opens.
Use `blankSpace` in AI chat applications where a sent message needs space below it to push it to the top of the viewport while the AI response streams in. The minimum padding ensures this space remains available without causing additional movement when the keyboard opens.
:::

:::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.
:::

:::warning iOS contentInset hit-test bug
On **iOS with React Native 0.81+**, the `contentInset` area created by `minimumContentPadding` may not respond to touch/scroll gestures due to [facebook/react-native#54123](https://github.com/facebook/react-native/issues/54123).
On **iOS with React Native 0.81+**, the `contentInset` area created by `blankSpace` may not respond to touch/scroll gestures due to [facebook/react-native#54123](https://github.com/facebook/react-native/issues/54123).

To fix this, you must either:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ export const KeyboardChatLegendList = typedForwardRef(
const refLegendList = useRef<LegendListRef | null>(null);
const combinedRef = useCombinedRef(forwardedRef, refLegendList);

const minimumContentPadding = useSharedValue<number>(0);
const blankSpace = useSharedValue<number>(0);

const calculateTopItemInset = useCallback(() => {
if (anchorToTopIndex === undefined || anchorToTopIndex < 0) {
minimumContentPadding.value = 0;
blankSpace.value = 0;
refLegendList.current?.reportContentInset(null);

return;
Expand Down Expand Up @@ -79,7 +79,7 @@ export const KeyboardChatLegendList = typedForwardRef(
state.scrollLength - contentBelowTopItem,
);

minimumContentPadding.value = calculatedInset;
blankSpace.value = calculatedInset;
refLegendList.current?.reportContentInset({ bottom: calculatedInset });
}, [anchorToTopIndex]);

Expand Down Expand Up @@ -121,12 +121,12 @@ export const KeyboardChatLegendList = typedForwardRef(
<KeyboardChatScrollView
{...scrollProps}
applyWorkaroundForContentInsetHitTestBug
blankSpace={blankSpace}
extraContentPadding={extraContentPadding}
minimumContentPadding={minimumContentPadding}
/>
);
},
[minimumContentPadding, extraContentPadding],
[blankSpace, extraContentPadding],
);

return (
Expand Down
15 changes: 6 additions & 9 deletions src/components/KeyboardChatScrollView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { KeyboardChatScrollViewProps } from "./types";
import type { LayoutChangeEvent } from "react-native";

const ZERO_CONTENT_PADDING = makeMutable(0);
const ZERO_MINIMUM_CONTENT_PADDING = makeMutable(0);
const ZERO_BLANK_SPACE = makeMutable(0);

const KeyboardChatScrollView = forwardRef<
Reanimated.ScrollView,
Expand All @@ -33,7 +33,7 @@ const KeyboardChatScrollView = forwardRef<
freeze = false,
offset = 0,
extraContentPadding = ZERO_CONTENT_PADDING,
minimumContentPadding = ZERO_MINIMUM_CONTENT_PADDING,
blankSpace = ZERO_BLANK_SPACE,
applyWorkaroundForContentInsetHitTestBug = false,
onLayout: onLayoutProp,
onContentSizeChange: onContentSizeChangeProp,
Expand All @@ -57,15 +57,15 @@ const KeyboardChatScrollView = forwardRef<
keyboardLiftBehavior,
freeze,
offset,
minimumContentPadding,
blankSpace,
extraContentPadding,
});

useExtraContentPadding({
scrollViewRef,
extraContentPadding,
keyboardPadding: padding,
minimumContentPadding,
blankSpace,
scroll,
layout,
size,
Expand All @@ -75,13 +75,10 @@ const KeyboardChatScrollView = forwardRef<
});

const totalPadding = useDerivedValue(() =>
Math.max(
minimumContentPadding.value,
padding.value + extraContentPadding.value,
),
Math.max(blankSpace.value, padding.value + extraContentPadding.value),
);

// Scroll indicator inset = keyboard + extraContentPadding (excludes minimumContentPadding).
// Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace).
// Apps that render into the unsafe area can supply a negative
// scrollIndicatorInsets adjustment at the application layer.
const indicatorPadding = useDerivedValue(
Expand Down
8 changes: 4 additions & 4 deletions src/components/KeyboardChatScrollView/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,17 @@ export type KeyboardChatScrollViewProps = {
* A shared value representing a minimum inset floor (in pixels).
*
* When set, the total bottom padding is computed as:
* `max(minimumContentPadding, keyboardPadding + extraContentPadding)`
* `max(blankSpace, keyboardPadding + extraContentPadding)`
*
* This means the keyboard "absorbs" into the minimum padding rather than adding to it:
* - When `minimumContentPadding >= keyboard + extraContentPadding`: content does NOT move on keyboard open/close.
* - When `minimumContentPadding < keyboard + extraContentPadding`: content moves, but only by the excess amount.
* - When `blankSpace >= keyboard + extraContentPadding`: content does NOT move on keyboard open/close.
* - When `blankSpace < keyboard + extraContentPadding`: content moves, but only by the excess amount.
*
* Useful in AI chat apps where a sent message needs space below it (to push it to the top
* of the viewport) while the AI response streams in, without that space causing extra movement
* when the keyboard opens.
*
* Default is `undefined` (equivalent to `0` — no minimum floor).
*/
minimumContentPadding?: SharedValue<number>;
blankSpace?: SharedValue<number>;
} & ScrollViewProps;
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ export function setupBeforeEach() {

type RenderOptions = Omit<
Parameters<typeof useChatKeyboard>[1],
"freeze" | "offset" | "minimumContentPadding" | "extraContentPadding"
"freeze" | "offset" | "blankSpace" | "extraContentPadding"
> & {
freeze?: boolean;
offset?: number;
minimumContentPadding?: SharedValue<number>;
blankSpace?: SharedValue<number>;
extraContentPadding?: SharedValue<number>;
};

Expand All @@ -103,7 +103,7 @@ export function createRender(modulePath: string) {
...options,
freeze: options.freeze ?? false,
offset: options.offset ?? 0,
minimumContentPadding: options.minimumContentPadding ?? sv(0),
blankSpace: options.blankSpace ?? sv(0),
extraContentPadding: options.extraContentPadding ?? sv(0),
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,28 +39,28 @@ beforeEach(() => {
setupBeforeEach();
});

describe("minimumContentPadding — Android non-inverted + always", () => {
it("minimumContentPadding=0 produces identical behavior to default", () => {
describe("blankSpace — Android non-inverted + always", () => {
it("blankSpace=0 produces identical behavior to default", () => {
mockOffset.value = 100;
render({
inverted: false,
keyboardLiftBehavior: "always",
minimumContentPadding: sv(0),
blankSpace: sv(0),
});

handlers.onStart({ height: KEYBOARD });
handlers.onMove({ height: 200 });
expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 300, false);
});

it("full absorption: no scroll movement when minimumContentPadding > keyboard", () => {
it("full absorption: no scroll movement when blankSpace > keyboard", () => {
// Small content so minimum padding fills viewport (pastContentEnd = 0+800-300 = 500, fraction = 1)
mockSize.value = { width: 390, height: 300 };
mockOffset.value = 0;
render({
inverted: false,
keyboardLiftBehavior: "always",
minimumContentPadding: sv(500),
blankSpace: sv(500),
});

handlers.onStart({ height: KEYBOARD });
Expand All @@ -78,7 +78,7 @@ describe("minimumContentPadding — Android non-inverted + always", () => {
render({
inverted: false,
keyboardLiftBehavior: "always",
minimumContentPadding: sv(100),
blankSpace: sv(100),
});

handlers.onStart({ height: KEYBOARD });
Expand All @@ -91,7 +91,7 @@ describe("minimumContentPadding — Android non-inverted + always", () => {
expect(mockScrollTo).toHaveBeenCalledWith(expect.anything(), 0, 100, false);
});

it("full absorption with extraContentPadding: absorbed = minimumContentPadding - extraContentPadding", () => {
it("full absorption with extraContentPadding: absorbed = blankSpace - extraContentPadding", () => {
// Small content so minimum padding fills viewport (pastContentEnd = 0+800-300 = 500, fraction = 1)
mockSize.value = { width: 390, height: 300 };
mockOffset.value = 0;
Expand All @@ -100,7 +100,7 @@ describe("minimumContentPadding — Android non-inverted + always", () => {
render({
inverted: false,
keyboardLiftBehavior: "always",
minimumContentPadding: sv(500),
blankSpace: sv(500),
extraContentPadding,
});

Expand All @@ -112,15 +112,15 @@ describe("minimumContentPadding — Android non-inverted + always", () => {
});
});

describe("minimumContentPadding — Android inverted + always", () => {
it("full absorption: no scroll movement when minimumContentPadding > keyboard", () => {
describe("blankSpace — Android inverted + always", () => {
it("full absorption: no scroll movement when blankSpace > keyboard", () => {
// Inverted: minimum padding at top, visible when scroll < 0
// fraction = -(-500)/500 = 1
mockOffset.value = -500;
render({
inverted: true,
keyboardLiftBehavior: "always",
minimumContentPadding: sv(500),
blankSpace: sv(500),
});

handlers.onStart({ height: KEYBOARD });
Expand All @@ -135,7 +135,7 @@ describe("minimumContentPadding — Android inverted + always", () => {
render({
inverted: true,
keyboardLiftBehavior: "always",
minimumContentPadding: sv(100),
blankSpace: sv(100),
});

handlers.onStart({ height: KEYBOARD });
Expand All @@ -146,13 +146,13 @@ describe("minimumContentPadding — Android inverted + always", () => {
});
});

describe("minimumContentPadding — Android never behavior", () => {
describe("blankSpace — Android never behavior", () => {
it("full absorption: no scroll on close (non-inverted)", () => {
mockOffset.value = 100;
render({
inverted: false,
keyboardLiftBehavior: "never",
minimumContentPadding: sv(500),
blankSpace: sv(500),
});

// Open keyboard
Expand All @@ -173,7 +173,7 @@ describe("minimumContentPadding — Android never behavior", () => {
render({
inverted: true,
keyboardLiftBehavior: "never",
minimumContentPadding: sv(500),
blankSpace: sv(500),
});

handlers.onStart({ height: KEYBOARD });
Expand All @@ -187,7 +187,7 @@ describe("minimumContentPadding — Android never behavior", () => {
});
});

describe("minimumContentPadding — Android whenAtEnd behavior", () => {
describe("blankSpace — Android whenAtEnd behavior", () => {
it("full absorption prevents scroll even when at end", () => {
// Small content so minimum padding fills viewport (pastContentEnd = 0+800-300 = 500, fraction = 1)
// Position at end: 0 + 800 >= 300 - 20
Expand All @@ -196,7 +196,7 @@ describe("minimumContentPadding — Android whenAtEnd behavior", () => {
render({
inverted: false,
keyboardLiftBehavior: "whenAtEnd",
minimumContentPadding: sv(500),
blankSpace: sv(500),
});

handlers.onStart({ height: KEYBOARD });
Expand Down
Loading
Loading