Skip to content

Commit ce8ccaa

Browse files
authored
feat: add onContentInsetChange callback (#1445)
## 📜 Description Added `onContentInsetChange` callback. ## 💡 Motivation and Context Modifying `contentInsets` changes scrollable area. In `react-native` this event propagates through `onScroll` event. In JS I can not modify it (this event) just because we have so many variation in terms of how to intercept the `onScroll` event (Animated, Reanimated, plain JS callback) so that for example for `Animated` with native driver I can not simply modify event structure, because I'll emit event from JS but this event is not possible to send to mobile. So I decided to add separate `onContentInsetChange` event. At the moment this callback will be needed in `LegendList` to implement accurate `scrollToEnd` and other functions that depends on precise scroll coordinates. ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### JS - added `onContentInsetChange` callback; ## 🤔 How Has This Been Tested? Tested manually. ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 1048f52 commit ce8ccaa

4 files changed

Lines changed: 110 additions & 17 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,22 @@ To fix this, you must either:
168168
Without one of these solutions, users won't be able to scroll or interact with content in the minimum padding area.
169169
:::
170170

171+
### `onContentInsetChange`
172+
173+
A callback fired whenever the effective content inset changes — i.e. the static [`contentInset`](https://reactnative.dev/docs/scrollview#contentinset) prop combined with the dynamic keyboard-driven padding (`keyboardPadding + extraContentPadding`, or `blankSpace` when it dominates).
174+
175+
```ts
176+
type Insets = { top: number; bottom: number; left: number; right: number };
177+
178+
onContentInsetChange?: (insets: Insets) => void;
179+
```
180+
181+
The callback is invoked on every animation frame during the keyboard transition (and once for any non-animated change to the static `contentInset` prop).
182+
183+
:::tip When to use it?
184+
On **Android**, the synthetic content inset is _not_ included in the native `onScroll` event payload (because the inset is simulated at the React Native layer rather than reported by the native `ScrollView`). Use `onContentInsetChange` to track the current inset alongside scroll offsets.
185+
:::
186+
171187
## Usage with virtualized lists
172188

173189
`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.

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,22 @@ To fix this, you must either:
168168
Without one of these solutions, users won't be able to scroll or interact with content in the minimum padding area.
169169
:::
170170

171+
### `onContentInsetChange`
172+
173+
A callback fired whenever the effective content inset changes — i.e. the static [`contentInset`](https://reactnative.dev/docs/scrollview#contentinset) prop combined with the dynamic keyboard-driven padding (`keyboardPadding + extraContentPadding`, or `blankSpace` when it dominates).
174+
175+
```ts
176+
type Insets = { top: number; bottom: number; left: number; right: number };
177+
178+
onContentInsetChange?: (insets: Insets) => void;
179+
```
180+
181+
The callback is invoked on every animation frame during the keyboard transition (and once for any non-animated change to the static `contentInset` prop).
182+
183+
:::tip When to use it?
184+
On **Android**, the synthetic content inset is _not_ included in the native `onScroll` event payload (because the inset is simulated at the React Native layer rather than reported by the native `ScrollView`). Use `onContentInsetChange` to track the current inset alongside scroll offsets.
185+
:::
186+
171187
## Usage with virtualized lists
172188

173189
`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.

src/components/KeyboardChatScrollView/types.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { AnimatedScrollViewComponent } from "../ScrollViewWithBottomPadding";
1+
import type {
2+
AnimatedScrollViewComponent,
3+
ScrollViewContentInsets,
4+
} from "../ScrollViewWithBottomPadding";
25
import type { KeyboardLiftBehavior } from "./useChatKeyboard/types";
36
import type { ScrollViewProps } from "react-native";
47
import type { SharedValue } from "react-native-reanimated";
@@ -87,4 +90,13 @@ export type KeyboardChatScrollViewProps = {
8790
* Default is `undefined` (equivalent to `0` — no minimum floor).
8891
*/
8992
blankSpace?: SharedValue<number>;
93+
/**
94+
* Fires whenever the effective content inset changes — the static `contentInset`
95+
* prop combined with the dynamic keyboard-driven padding.
96+
*
97+
* Useful on Android, where the synthetic content inset is not reflected in the native
98+
* `onScroll` event payload. Consumers such as virtualized lists computing their own
99+
* `scrollToEnd` target can use this to track the current inset alongside scroll offsets.
100+
*/
101+
onContentInsetChange?: (insets: ScrollViewContentInsets) => void;
90102
} & ScrollViewProps;

src/components/ScrollViewWithBottomPadding/index.tsx

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import React, { forwardRef } from "react";
22
import { Platform } from "react-native";
33
import Reanimated, {
4+
runOnJS,
45
useAnimatedProps,
6+
useAnimatedReaction,
7+
useDerivedValue,
58
useSharedValue,
69
} from "react-native-reanimated";
710

@@ -26,6 +29,13 @@ export type AnimatedScrollViewComponent = React.ForwardRefExoticComponent<
2629
AnimatedScrollViewProps & React.RefAttributes<Reanimated.ScrollView>
2730
>;
2831

32+
export type ScrollViewContentInsets = {
33+
top: number;
34+
bottom: number;
35+
left: number;
36+
right: number;
37+
};
38+
2939
type ScrollViewWithBottomPaddingProps = {
3040
ScrollViewComponent: AnimatedScrollViewComponent;
3141
children?: React.ReactNode;
@@ -36,6 +46,12 @@ type ScrollViewWithBottomPaddingProps = {
3646
/** Absolute Y content offset (iOS only, for KeyboardChatScrollView). */
3747
contentOffsetY?: SharedValue<number>;
3848
applyWorkaroundForContentInsetHitTestBug?: boolean;
49+
/**
50+
* Fires whenever the effective content inset changes (combines the static `contentInset`
51+
* prop with the dynamic keyboard-driven padding). Useful on Android where the synthetic
52+
* inset is not reflected in `onScroll` events.
53+
*/
54+
onContentInsetChange?: (insets: ScrollViewContentInsets) => void;
3955
} & ScrollViewProps;
4056

4157
const ScrollViewWithBottomPadding = forwardRef<
@@ -52,18 +68,60 @@ const ScrollViewWithBottomPadding = forwardRef<
5268
inverted,
5369
contentOffsetY,
5470
applyWorkaroundForContentInsetHitTestBug,
71+
onContentInsetChange,
5572
children,
5673
...rest
5774
},
5875
ref,
5976
) => {
6077
const prevContentOffsetY = useSharedValue<number | null>(null);
6178

79+
const insets = useDerivedValue(() => {
80+
const dynamicTop = inverted ? bottomPadding.value : 0;
81+
const dynamicBottom = !inverted ? bottomPadding.value : 0;
82+
83+
return {
84+
dynamic: {
85+
top: dynamicTop,
86+
bottom: dynamicBottom,
87+
},
88+
effective: {
89+
top: dynamicTop + (contentInset?.top || 0),
90+
bottom: dynamicBottom + (contentInset?.bottom || 0),
91+
left: contentInset?.left || 0,
92+
right: contentInset?.right || 0,
93+
} as ScrollViewContentInsets,
94+
};
95+
}, [
96+
inverted,
97+
contentInset?.top,
98+
contentInset?.bottom,
99+
contentInset?.left,
100+
contentInset?.right,
101+
]);
102+
103+
useAnimatedReaction(
104+
() => insets.value.effective,
105+
(current, previous) => {
106+
if (!onContentInsetChange) {
107+
return;
108+
}
109+
if (
110+
previous &&
111+
current.top === previous.top &&
112+
current.bottom === previous.bottom &&
113+
current.left === previous.left &&
114+
current.right === previous.right
115+
) {
116+
return;
117+
}
118+
runOnJS(onContentInsetChange)(current);
119+
},
120+
[onContentInsetChange],
121+
);
122+
62123
const animatedProps = useAnimatedProps(() => {
63-
const insetTop = inverted ? bottomPadding.value : 0;
64-
const insetBottom = !inverted ? bottomPadding.value : 0;
65-
const bottom = insetBottom + (contentInset?.bottom || 0);
66-
const top = insetTop + (contentInset?.top || 0);
124+
const { dynamic, effective } = insets.value;
67125

68126
const indicatorPadding = scrollIndicatorPadding ?? bottomPadding;
69127
const indicatorTop =
@@ -75,21 +133,16 @@ const ScrollViewWithBottomPadding = forwardRef<
75133

76134
const result: Record<string, unknown> = {
77135
// iOS prop
78-
contentInset: {
79-
bottom: bottom,
80-
top: top,
81-
right: contentInset?.right,
82-
left: contentInset?.left,
83-
},
136+
contentInset: effective,
84137
scrollIndicatorInsets: {
85138
bottom: indicatorBottom,
86139
top: indicatorTop,
87140
right: scrollIndicatorInsets?.right,
88141
left: scrollIndicatorInsets?.left,
89142
},
90143
// Android prop
91-
contentInsetBottom: insetBottom,
92-
contentInsetTop: insetTop,
144+
contentInsetBottom: dynamic.bottom,
145+
contentInsetTop: dynamic.top,
93146
};
94147

95148
if (contentOffsetY) {
@@ -104,10 +157,6 @@ const ScrollViewWithBottomPadding = forwardRef<
104157

105158
return result;
106159
}, [
107-
contentInset?.bottom,
108-
contentInset?.top,
109-
contentInset?.right,
110-
contentInset?.left,
111160
scrollIndicatorInsets?.bottom,
112161
scrollIndicatorInsets?.top,
113162
scrollIndicatorInsets?.right,

0 commit comments

Comments
 (0)