Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | 1x 13x 13x 13x 13x 6x 13x 5x 13x 13x 2x 2x 16x 13x 13x 3x 3x 13x | import { useCallback, useEffect, useRef } from "react";
import { useKeyboardHandler } from "react-native-keyboard-controller";
import { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { FlashListRef } from "@shopify/flash-list";
import { ChatMessage } from "@/types/chat";
/**
* Keeps the newest message in view. The keyboard height is tracked as a shared
* value so the list can pad itself on the UI thread rather than re-rendering
* on every frame of the keyboard transition.
*/
export const useChatScroll = (messages: ChatMessage[]) => {
const listRef = useRef<FlashListRef<ChatMessage>>(null);
const keyboardHeight = useSharedValue(0);
/** Which way the keyboard is travelling — only an opening one pulls the list. */
const isOpening = useSharedValue(false);
const scrollToBottom = useCallback((animated = true) => {
listRef.current?.scrollToEnd({ animated });
}, []);
useKeyboardHandler({
/**
* `onStart` carries the DESTINATION height, which is the only place the
* direction is known: a live frame reads the same either way, since a
* closing keyboard is still hundreds of points tall for most of its
* travel. Reading direction off the frame height is what made dismissing
* the keyboard drag the reader back down to the newest message.
*/
onStart: (e) => {
"worklet";
isOpening.value = e.height > 0;
},
/**
* The padding sits on the view *around* the list, so an opening keyboard
* shrinks the viewport while the content stays put — the end of the list
* drops below the fold by exactly the keyboard's height.
*
* So the scroll has to follow the shrink frame by frame rather than fire
* once. Scrolling before it (on start) measures a viewport that is about
* to change and lands short; scrolling only after it (on end) is correct
* but cannot begin until the keyboard has finished, which is the delay.
* Unanimated, because the frame cadence is already the animation.
*/
onMove: (e) => {
"worklet";
keyboardHeight.value = e.height;
if (isOpening.value) runOnJS(scrollToBottom)(false);
},
// Final correction against the settled layout. Normally a no-op — onMove
// has already arrived — but it costs one call and guarantees the landing.
onEnd: (e) => {
"worklet";
keyboardHeight.value = e.height;
if (isOpening.value) runOnJS(scrollToBottom)(false);
},
});
// Assigned straight through: `onMove` already delivers the keyboard's own
// interpolation frame by frame, so re-animating it with `withTiming` only
// made the padding chase a value that was itself still moving — which is
// what left the list trailing behind the keyboard.
const animatedChatViewStyle = useAnimatedStyle(() => ({
paddingBottom: keyboardHeight.value,
}));
useEffect(() => {
if (messages.length === 0) return;
// One frame, not a fixed delay: the new message has to be laid out before
// the list knows where its end is, but waiting a flat 50ms for that is
// both longer than it needs to be and no guarantee on a busy JS thread.
const frame = requestAnimationFrame(() => scrollToBottom());
return () => cancelAnimationFrame(frame);
}, [messages, scrollToBottom]);
return { listRef, keyboardHeight, animatedChatViewStyle };
};
|