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
37 changes: 37 additions & 0 deletions docs/docs/api/components/keyboard-chat-scroll-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,43 @@ The callback is invoked on every animation frame during the keyboard transition
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.
:::

### `onEndVisible`

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.

```ts
onEndVisible?: (visible: boolean) => void;
```

- For **non-inverted** lists, the "end" is the bottom of the content (the newest messages, in a typical chat).
- For **inverted** lists (`inverted={true}`), the "end" is the top of the scroll view (where the latest messages are rendered).

The callback uses the same internal "at end" detection that powers `keyboardLiftBehavior="whenAtEnd"`, so the value stays in sync with the lift decision.

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.

:::tip When to use it?
The classic case is a **jump-to-latest** affordance in a chat UI:

```tsx
const [showJump, setShowJump] = useState(false);

<KeyboardChatScrollView onEndVisible={(visible) => setShowJump(!visible)}>
{/* ...messages... */}
</KeyboardChatScrollView>;
```

:::

:::note Worklet support
The callback can be either a plain JS function **or** a Reanimated worklet — the type is detected automatically:

- A **worklet** (a function with the `'worklet'` directive) runs on the UI thread, with no JS thread hop.
- A **plain JS function** is dispatched via `runOnJS` and runs on the JS thread.

Use a worklet when you want to drive UI animations (e.g. fade a jump-to-latest button) without bouncing through React state.
:::

## 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,43 @@ The callback is invoked on every animation frame during the keyboard transition
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.
:::

### `onEndVisible`

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.

```ts
onEndVisible?: (visible: boolean) => void;
```

- For **non-inverted** lists, the "end" is the bottom of the content (the newest messages, in a typical chat).
- For **inverted** lists (`inverted={true}`), the "end" is the top of the scroll view (where the latest messages are rendered).

The callback uses the same internal "at end" detection that powers `keyboardLiftBehavior="whenAtEnd"`, so the value stays in sync with the lift decision.

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.

:::tip When to use it?
The classic case is a **jump-to-latest** affordance in a chat UI:

```tsx
const [showJump, setShowJump] = useState(false);

<KeyboardChatScrollView onEndVisible={(visible) => setShowJump(!visible)}>
{/* ...messages... */}
</KeyboardChatScrollView>;
```

:::

:::note Worklet support
The callback can be either a plain JS function **or** a Reanimated worklet — the type is detected automatically:

- A **worklet** (a function with the `'worklet'` directive) runs on the UI thread, with no JS thread hop.
- A **plain JS function** is dispatched via `runOnJS` and runs on the JS thread.

Use a worklet when you want to drive UI animations (e.g. fade a jump-to-latest button) without bouncing through React state.
:::

## 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.
Expand Down
10 changes: 10 additions & 0 deletions src/components/KeyboardChatScrollView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import useCombinedRef from "../hooks/useCombinedRef";
import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding";

import { useChatKeyboard } from "./useChatKeyboard";
import { useEndVisible } from "./useEndVisible";
import { useExtraContentPadding } from "./useExtraContentPadding";

import type { KeyboardChatScrollViewProps } from "./types";
Expand All @@ -37,6 +38,7 @@ const KeyboardChatScrollView = forwardRef<
applyWorkaroundForContentInsetHitTestBug = false,
onLayout: onLayoutProp,
onContentSizeChange: onContentSizeChangeProp,
onEndVisible,
...rest
},
ref,
Expand Down Expand Up @@ -78,6 +80,14 @@ const KeyboardChatScrollView = forwardRef<
freeze: freezeSV,
});

useEndVisible({
scroll,
layout,
size,
inverted,
onEndVisible,
});

const totalPadding = useDerivedValue(() =>
Math.max(blankSpace.value, padding.value + extraContentPadding.value),
);
Expand Down
15 changes: 15 additions & 0 deletions src/components/KeyboardChatScrollView/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,19 @@ export type KeyboardChatScrollViewProps = {
* `scrollToEnd` target can use this to track the current inset alongside scroll offsets.
*/
onContentInsetChange?: (insets: ScrollViewContentInsets) => void;
/**
* Fires 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.
*
* For non-inverted lists, the "end" is the bottom of the content. For inverted lists
* (`inverted={true}`), the "end" is the top of the scroll view, where the latest messages
* are rendered. The same internal detection drives `keyboardLiftBehavior="whenAtEnd"`.
*
* The callback can be either a plain JS function or a Reanimated worklet — the type is
* detected automatically. Worklets run on the UI thread; plain functions are dispatched
* via `runOnJS`.
*
* Fires once on mount with the initial state (after the scroll view has been measured).
*/
onEndVisible?: (visible: boolean) => void;
} & ScrollViewProps;
66 changes: 66 additions & 0 deletions src/components/KeyboardChatScrollView/useEndVisible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useMemo } from "react";
import {
runOnJS,
useAnimatedReaction,
useDerivedValue,
} from "react-native-reanimated";

import { isScrollAtEnd } from "./useChatKeyboard/helpers";

import type { SharedValue } from "react-native-reanimated";

type EndVisibleCallback = (visible: boolean) => void;

type Options = {
scroll: SharedValue<number>;
layout: SharedValue<{ width: number; height: number }>;
size: SharedValue<{ width: number; height: number }>;
inverted: boolean;
onEndVisible?: EndVisibleCallback;
};

const hasWorkletHash = (value: unknown): boolean =>
typeof value === "function" &&
!!(value as unknown as Record<string, unknown>).__workletHash;

export const useEndVisible = ({
scroll,
layout,
size,
inverted,
onEndVisible,
}: Options) => {
const isWorklet = useMemo(() => hasWorkletHash(onEndVisible), [onEndVisible]);

const isAtEnd = useDerivedValue(() => {
// Wait until the scroll view has been measured to avoid a spurious initial
// `true` on a (0,0,0) layout (the helper would otherwise treat unmeasured
// state as "at end" because 0 + 0 >= 0 - threshold).
if (layout.value.height === 0 || size.value.height === 0) {
return null;
}

return isScrollAtEnd(
scroll.value,
layout.value.height,
size.value.height,
inverted,
);
});

useAnimatedReaction(
() => isAtEnd.value,
(current, previous) => {
if (current === null || current === previous || !onEndVisible) {
return;
}

if (isWorklet) {
onEndVisible(current);
} else {
runOnJS(onEndVisible)(current);
}
},
[onEndVisible, isWorklet, inverted],
);
};
Loading