Skip to content

Commit 5dc5643

Browse files
authored
feat: expose onEndVisible callback from KeyboardChatScrollView (#1450)
## 📜 Description Added `onEndVisible` callback for `KeyboardChatScrollView`. ## 💡 Motivation and Context <!-- Why is this change required? What problem does it solve? --> <!-- If it fixes an open issue, please link to the issue here. --> Originally these changes were requested in #1430 for implementing functionality like "jump-to-end". I kind of agree, that we were computing the `isAtEnd` internally, so technically it's really not so difficult to expose it outside and it's really useful to have a logic for controlling lift behavior and end detection unified/reusable/the same. So in this PR I'm adding this callback. It has support for both worklet and plain JS function. Closes #1431 #1430 ## 📢 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 `onEndVisible` callback to `KeyboardChatScrollView`; ### Docs - added documentation about `onEndVisible` callback; ## 🤔 How Has This Been Tested? Tested on iPhone 17 Pro (iOS 26.2). ## 📸 Screenshots (if appropriate): <img width="1158" height="89" alt="image" src="https://github.com/user-attachments/assets/b6ed7c35-39a9-420d-ad7c-914787dfe7f0" /> ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 21a939f commit 5dc5643

5 files changed

Lines changed: 165 additions & 0 deletions

File tree

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,43 @@ The callback is invoked on every animation frame during the keyboard transition
184184
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.
185185
:::
186186

187+
### `onEndVisible`
188+
189+
A callback fired whenever the visibility of the content "end" changes — **both** when the user arrives at the end and when they leave it. The boolean parameter reflects the new state.
190+
191+
```ts
192+
onEndVisible?: (visible: boolean) => void;
193+
```
194+
195+
- For **non-inverted** lists, the "end" is the bottom of the content (the newest messages, in a typical chat).
196+
- For **inverted** lists (`inverted={true}`), the "end" is the top of the scroll view (where the latest messages are rendered).
197+
198+
The callback uses the same internal "at end" detection that powers `keyboardLiftBehavior="whenAtEnd"`, so the value stays in sync with the lift decision.
199+
200+
The callback fires on every transition in either direction (unlike `FlatList`'s `onEndReached`, which fires only on entry). It also fires once on mount with the initial value, after the scroll view has been measured.
201+
202+
:::tip When to use it?
203+
The classic case is a **jump-to-latest** affordance in a chat UI:
204+
205+
```tsx
206+
const [showJump, setShowJump] = useState(false);
207+
208+
<KeyboardChatScrollView onEndVisible={(visible) => setShowJump(!visible)}>
209+
{/* ...messages... */}
210+
</KeyboardChatScrollView>;
211+
```
212+
213+
:::
214+
215+
:::note Worklet support
216+
The callback can be either a plain JS function **or** a Reanimated worklet — the type is detected automatically:
217+
218+
- A **worklet** (a function with the `'worklet'` directive) runs on the UI thread, with no JS thread hop.
219+
- A **plain JS function** is dispatched via `runOnJS` and runs on the JS thread.
220+
221+
Use a worklet when you want to drive UI animations (e.g. fade a jump-to-latest button) without bouncing through React state.
222+
:::
223+
187224
## Usage with virtualized lists
188225

189226
`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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,43 @@ The callback is invoked on every animation frame during the keyboard transition
184184
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.
185185
:::
186186

187+
### `onEndVisible`
188+
189+
A callback fired whenever the visibility of the content "end" changes — **both** when the user arrives at the end and when they leave it. The boolean parameter reflects the new state.
190+
191+
```ts
192+
onEndVisible?: (visible: boolean) => void;
193+
```
194+
195+
- For **non-inverted** lists, the "end" is the bottom of the content (the newest messages, in a typical chat).
196+
- For **inverted** lists (`inverted={true}`), the "end" is the top of the scroll view (where the latest messages are rendered).
197+
198+
The callback uses the same internal "at end" detection that powers `keyboardLiftBehavior="whenAtEnd"`, so the value stays in sync with the lift decision.
199+
200+
The callback fires on every transition in either direction (unlike `FlatList`'s `onEndReached`, which fires only on entry). It also fires once on mount with the initial value, after the scroll view has been measured.
201+
202+
:::tip When to use it?
203+
The classic case is a **jump-to-latest** affordance in a chat UI:
204+
205+
```tsx
206+
const [showJump, setShowJump] = useState(false);
207+
208+
<KeyboardChatScrollView onEndVisible={(visible) => setShowJump(!visible)}>
209+
{/* ...messages... */}
210+
</KeyboardChatScrollView>;
211+
```
212+
213+
:::
214+
215+
:::note Worklet support
216+
The callback can be either a plain JS function **or** a Reanimated worklet — the type is detected automatically:
217+
218+
- A **worklet** (a function with the `'worklet'` directive) runs on the UI thread, with no JS thread hop.
219+
- A **plain JS function** is dispatched via `runOnJS` and runs on the JS thread.
220+
221+
Use a worklet when you want to drive UI animations (e.g. fade a jump-to-latest button) without bouncing through React state.
222+
:::
223+
187224
## Usage with virtualized lists
188225

189226
`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/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import useCombinedRef from "../hooks/useCombinedRef";
1212
import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding";
1313

1414
import { useChatKeyboard } from "./useChatKeyboard";
15+
import { useEndVisible } from "./useEndVisible";
1516
import { useExtraContentPadding } from "./useExtraContentPadding";
1617

1718
import type { KeyboardChatScrollViewProps } from "./types";
@@ -37,6 +38,7 @@ const KeyboardChatScrollView = forwardRef<
3738
applyWorkaroundForContentInsetHitTestBug = false,
3839
onLayout: onLayoutProp,
3940
onContentSizeChange: onContentSizeChangeProp,
41+
onEndVisible,
4042
...rest
4143
},
4244
ref,
@@ -78,6 +80,14 @@ const KeyboardChatScrollView = forwardRef<
7880
freeze: freezeSV,
7981
});
8082

83+
useEndVisible({
84+
scroll,
85+
layout,
86+
size,
87+
inverted,
88+
onEndVisible,
89+
});
90+
8191
const totalPadding = useDerivedValue(() =>
8292
Math.max(blankSpace.value, padding.value + extraContentPadding.value),
8393
);

src/components/KeyboardChatScrollView/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,19 @@ export type KeyboardChatScrollViewProps = {
9999
* `scrollToEnd` target can use this to track the current inset alongside scroll offsets.
100100
*/
101101
onContentInsetChange?: (insets: ScrollViewContentInsets) => void;
102+
/**
103+
* Fires whenever the visibility of the content "end" changes — both when the user
104+
* arrives at the end and when they leave it. The boolean parameter reflects the new state.
105+
*
106+
* For non-inverted lists, the "end" is the bottom of the content. For inverted lists
107+
* (`inverted={true}`), the "end" is the top of the scroll view, where the latest messages
108+
* are rendered. The same internal detection drives `keyboardLiftBehavior="whenAtEnd"`.
109+
*
110+
* The callback can be either a plain JS function or a Reanimated worklet — the type is
111+
* detected automatically. Worklets run on the UI thread; plain functions are dispatched
112+
* via `runOnJS`.
113+
*
114+
* Fires once on mount with the initial state (after the scroll view has been measured).
115+
*/
116+
onEndVisible?: (visible: boolean) => void;
102117
} & ScrollViewProps;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useMemo } from "react";
2+
import {
3+
runOnJS,
4+
useAnimatedReaction,
5+
useDerivedValue,
6+
} from "react-native-reanimated";
7+
8+
import { isScrollAtEnd } from "./useChatKeyboard/helpers";
9+
10+
import type { SharedValue } from "react-native-reanimated";
11+
12+
type EndVisibleCallback = (visible: boolean) => void;
13+
14+
type Options = {
15+
scroll: SharedValue<number>;
16+
layout: SharedValue<{ width: number; height: number }>;
17+
size: SharedValue<{ width: number; height: number }>;
18+
inverted: boolean;
19+
onEndVisible?: EndVisibleCallback;
20+
};
21+
22+
const hasWorkletHash = (value: unknown): boolean =>
23+
typeof value === "function" &&
24+
!!(value as unknown as Record<string, unknown>).__workletHash;
25+
26+
export const useEndVisible = ({
27+
scroll,
28+
layout,
29+
size,
30+
inverted,
31+
onEndVisible,
32+
}: Options) => {
33+
const isWorklet = useMemo(() => hasWorkletHash(onEndVisible), [onEndVisible]);
34+
35+
const isAtEnd = useDerivedValue(() => {
36+
// Wait until the scroll view has been measured to avoid a spurious initial
37+
// `true` on a (0,0,0) layout (the helper would otherwise treat unmeasured
38+
// state as "at end" because 0 + 0 >= 0 - threshold).
39+
if (layout.value.height === 0 || size.value.height === 0) {
40+
return null;
41+
}
42+
43+
return isScrollAtEnd(
44+
scroll.value,
45+
layout.value.height,
46+
size.value.height,
47+
inverted,
48+
);
49+
});
50+
51+
useAnimatedReaction(
52+
() => isAtEnd.value,
53+
(current, previous) => {
54+
if (current === null || current === previous || !onEndVisible) {
55+
return;
56+
}
57+
58+
if (isWorklet) {
59+
onEndVisible(current);
60+
} else {
61+
runOnJS(onEndVisible)(current);
62+
}
63+
},
64+
[onEndVisible, isWorklet, inverted],
65+
);
66+
};

0 commit comments

Comments
 (0)