fix up KeyboardAvoidingLegendList to support KeyboardGestureArea and improve interactive

This commit is contained in:
Jay Meistrich
2025-12-14 19:23:12 +09:00
parent 483465c771
commit f8ba53db93
2 changed files with 131 additions and 60 deletions
+23 -21
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Button, StyleSheet, Text, TextInput, View } from "react-native";
import { KeyboardProvider, KeyboardStickyView } from "react-native-keyboard-controller";
import { type PropsWithChildren, useState } from "react";
import { Button, Platform, StyleSheet, Text, TextInput, View } from "react-native";
import { KeyboardGestureArea, KeyboardProvider, KeyboardStickyView } from "react-native-keyboard-controller";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";
@@ -129,14 +129,14 @@ const ChatKeyboard = () => {
const sendMessage = () => {
const text = inputText || "Empty message";
if (text.trim()) {
setMessages((messages) => [
...messages,
setMessages((messagesNew) => [
...messagesNew,
{ id: String(idCounter++), sender: "user", text: text, timeStamp: Date.now() },
]);
setInputText("");
setTimeout(() => {
setMessages((messages) => [
...messages,
setMessages((messagesNew) => [
...messagesNew,
{
id: String(idCounter++),
sender: "bot",
@@ -151,19 +151,21 @@ const ChatKeyboard = () => {
return (
<KeyboardProvider>
<SafeAreaView edges={["bottom"]} style={styles.container}>
<KeyboardAvoidingLegendList
alignItemsAtEnd
contentContainerStyle={styles.contentContainer}
data={messages}
estimatedItemSize={80}
initialScrollIndex={messages.length - 1}
keyExtractor={(item) => item.id}
maintainScrollAtEnd
maintainVisibleContentPosition
renderItem={ChatMessage}
safeAreaInsetBottom={insets.bottom}
style={styles.list}
/>
<KeyboardGestureArea interpolator="ios" offset={60} style={styles.container}>
<KeyboardAvoidingLegendList
alignItemsAtEnd
contentContainerStyle={styles.contentContainer}
data={messages}
estimatedItemSize={80}
initialScrollIndex={messages.length - 1}
keyExtractor={(item) => item.id}
maintainScrollAtEnd
maintainVisibleContentPosition
renderItem={ChatMessage}
safeAreaInsetBottom={insets.bottom}
style={styles.list}
/>
</KeyboardGestureArea>
<KeyboardStickyView offset={{ closed: 0, opened: insets.bottom }}>
<View style={styles.inputContainer}>
<TextInput
@@ -194,7 +196,7 @@ const styles = StyleSheet.create({
},
contentContainer: {
paddingHorizontal: 16,
paddingTop: 96,
// paddingTop: 96,
},
input: {
borderColor: "#ccc",
+108 -39
View File
@@ -1,7 +1,7 @@
// biome-ignore lint/correctness/noUnusedImports: Leaving this out makes it crash in some environments
import * as React from "react";
import { type ForwardedRef, forwardRef, useCallback, useRef } from "react";
import { type Insets, Platform, type ScrollViewProps } from "react-native";
import { type Insets, Platform, type ScrollViewProps, StyleSheet, ViewProps } from "react-native";
import { useKeyboardHandler } from "react-native-keyboard-controller";
import type Animated from "react-native-reanimated";
import {
@@ -20,10 +20,11 @@ import { useCombinedRef } from "@/hooks/useCombinedRef";
type KeyboardControllerLegendListProps<ItemT> = Omit<AnimatedLegendListProps<ItemT>, "onScroll" | "contentInset"> & {
onScroll?: (event: ReanimatedScrollEvent) => void;
contentInset?: Insets;
contentInset?: Insets | undefined;
safeAreaInsetBottom?: number;
};
// biome-ignore lint/nursery/noShadow: const function name shadowing is intentional
export const KeyboardAvoidingLegendList = (forwardRef as TypedForwardRef)(function KeyboardAvoidingLegendList<ItemT>(
props: KeyboardControllerLegendListProps<ItemT>,
forwardedRef: ForwardedRef<LegendListRef>,
@@ -33,23 +34,31 @@ export const KeyboardAvoidingLegendList = (forwardRef as TypedForwardRef)(functi
horizontal,
onScroll: onScrollProp,
safeAreaInsetBottom = 0,
style: styleProp,
...rest
} = props;
const styleFlattened = StyleSheet.flatten(styleProp) as ScrollViewProps;
const refLegendList = useRef<LegendListRef | null>(null);
const combinedRef = useCombinedRef(forwardedRef, refLegendList);
const isIos = Platform.OS === "ios";
const isAndroid = Platform.OS === "android";
const scrollViewRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffsetY = useSharedValue(0);
const animatedOffsetY = useSharedValue<number | null>(null);
const scrollOffsetAtKeyboardStart = useSharedValue(0);
const mode = useSharedValue<"idle" | "running">("idle");
const keyboardInset = useSharedValue(0);
const keyboardHeight = useSharedValue(0);
const isOpening = useSharedValue(false);
const didInteractive = useSharedValue(false);
// Track keyboard open state to ignore spurious iOS keyboard events
const isKeyboardOpen = useSharedValue(false);
const scrollHandler = useAnimatedScrollHandler(
(event) => {
scrollOffsetY.value = event.contentOffset[horizontal ? "x" : "y"];
scrollOffsetY.set(event.contentOffset[horizontal ? "x" : "y"]);
if (onScrollProp) {
runOnJS(onScrollProp)(event);
@@ -71,50 +80,105 @@ export const KeyboardAvoidingLegendList = (forwardRef as TypedForwardRef)(functi
onStart: (event) => {
"worklet";
if (event.height > 0) {
keyboardHeight.set(event.height - safeAreaInsetBottom);
mode.set("running");
// Ignore spurious events when keyboard is already open
if (isKeyboardOpen.get() && event.progress === 1 && event.height > 0) {
return;
}
isOpening.set(event.progress > 0);
if (!didInteractive.get()) {
if (event.height > 0) {
keyboardHeight.set(event.height - safeAreaInsetBottom);
}
scrollOffsetAtKeyboardStart.value = scrollOffsetY.value;
animatedOffsetY.set(scrollOffsetY.value);
runOnJS(setScrollProcessingEnabled)(false);
isOpening.set(event.progress > 0);
scrollOffsetAtKeyboardStart.set(scrollOffsetY.get());
animatedOffsetY.set(scrollOffsetY.get());
runOnJS(setScrollProcessingEnabled)(false);
}
},
onInteractive: (event) => {
"worklet";
if (mode.get() !== "running") {
runOnJS(setScrollProcessingEnabled)(false);
}
mode.set("running");
if (!didInteractive.get()) {
didInteractive.set(true);
}
if (isAndroid && !horizontal) {
keyboardInset.set(Math.max(0, event.height - safeAreaInsetBottom));
}
},
onMove: (event) => {
"worklet";
const vIsOpening = isOpening.get();
const vKeyboardHeight = keyboardHeight.get();
const vProgress = vIsOpening ? event.progress : 1 - event.progress;
if (!didInteractive.get()) {
const vIsOpening = isOpening.get();
const vKeyboardHeight = keyboardHeight.get();
const vProgress = vIsOpening ? event.progress : 1 - event.progress;
const targetOffset =
scrollOffsetAtKeyboardStart.value + (vIsOpening ? vKeyboardHeight : -vKeyboardHeight) * vProgress;
scrollOffsetY.value = targetOffset;
animatedOffsetY.set(targetOffset);
const targetOffset = Math.max(
0,
scrollOffsetAtKeyboardStart.get() +
(vIsOpening ? vKeyboardHeight : -vKeyboardHeight) * vProgress,
);
scrollOffsetY.set(targetOffset);
animatedOffsetY.set(targetOffset);
if (!horizontal) {
keyboardInset.value = Math.max(0, event.height - safeAreaInsetBottom);
if (!horizontal) {
keyboardInset.set(Math.max(0, event.height - safeAreaInsetBottom));
}
}
},
onEnd: (event) => {
"worklet";
const vIsOpening = isOpening.get();
const vKeyboardHeight = keyboardHeight.get();
const wasInteractive = didInteractive.get();
const targetOffset =
scrollOffsetAtKeyboardStart.value +
(vIsOpening ? vKeyboardHeight : -vKeyboardHeight) *
(vIsOpening ? event.progress : 1 - event.progress);
const vMode = mode.get();
mode.set("idle");
scrollOffsetY.value = targetOffset;
animatedOffsetY.set(targetOffset);
if (vMode === "running") {
if (!wasInteractive) {
const vIsOpening = isOpening.get();
const vKeyboardHeight = keyboardHeight.get();
if (!horizontal) {
keyboardInset.value = Math.max(0, event.height - safeAreaInsetBottom);
const targetOffset = Math.max(
0,
scrollOffsetAtKeyboardStart.get() +
(vIsOpening ? vKeyboardHeight : -vKeyboardHeight) *
(vIsOpening ? event.progress : 1 - event.progress),
);
// Set both scrollOffsetY and animatedOffsetY so that it sets the new scroll position
// and also makes sure scrollOffsetY is up to date
scrollOffsetY.set(targetOffset);
animatedOffsetY.set(targetOffset);
}
runOnJS(setScrollProcessingEnabled)(true);
didInteractive.set(false);
isKeyboardOpen.set(event.height > 0);
if (!horizontal) {
const newInset = Math.max(0, event.height - safeAreaInsetBottom);
if (newInset > 0) {
keyboardInset.set(newInset);
} else {
keyboardInset.set(newInset);
animatedOffsetY.set(scrollOffsetY.get());
}
}
}
runOnJS(setScrollProcessingEnabled)(true);
},
},
[scrollViewRef, safeAreaInsetBottom],
@@ -123,22 +187,24 @@ export const KeyboardAvoidingLegendList = (forwardRef as TypedForwardRef)(functi
const animatedProps = useAnimatedProps<ScrollViewProps>(() => {
"worklet";
const vAnimatedOffsetY = animatedOffsetY.get() as number | null;
// Setting contentOffset animates the scroll with the keyboard
const baseProps: ScrollViewProps = {
contentOffset:
animatedOffsetY.value === null
vAnimatedOffsetY === null
? undefined
: {
x: 0,
y: animatedOffsetY.value,
y: vAnimatedOffsetY,
},
};
// On iOS we can use contentInset to pad from the bottom
return Platform.OS === "ios"
return isIos
? Object.assign(baseProps, {
contentInset: {
bottom: (contentInsetProp?.bottom ?? 0) + (horizontal ? 0 : keyboardInset.value),
bottom: (contentInsetProp?.bottom ?? 0) + (horizontal ? 0 : keyboardInset.get()),
left: contentInsetProp?.left ?? 0,
right: contentInsetProp?.right ?? 0,
top: contentInsetProp?.top ?? 0,
@@ -148,12 +214,15 @@ export const KeyboardAvoidingLegendList = (forwardRef as TypedForwardRef)(functi
});
// contentInset is not supported on Android so we have to use marginBottom instead
const style =
Platform.OS !== "ios"
? useAnimatedStyle(() => ({
marginBottom: keyboardInset.value,
}))
: undefined;
const style = isAndroid
? useAnimatedStyle(
() => ({
...(styleFlattened || {}),
marginBottom: keyboardInset.get() ?? 0,
}),
[styleProp, keyboardInset],
)
: undefined;
return (
<AnimatedLegendList