diff --git a/example/app/chat-keyboard/index.tsx b/example/app/chat-keyboard/index.tsx index 95b6cdf3..623db410 100644 --- a/example/app/chat-keyboard/index.tsx +++ b/example/app/chat-keyboard/index.tsx @@ -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 ( - item.id} - maintainScrollAtEnd - maintainVisibleContentPosition - renderItem={ChatMessage} - safeAreaInsetBottom={insets.bottom} - style={styles.list} - /> + + item.id} + maintainScrollAtEnd + maintainVisibleContentPosition + renderItem={ChatMessage} + safeAreaInsetBottom={insets.bottom} + style={styles.list} + /> + = Omit, "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( props: KeyboardControllerLegendListProps, forwardedRef: ForwardedRef, @@ -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(null); const combinedRef = useCombinedRef(forwardedRef, refLegendList); + const isIos = Platform.OS === "ios"; + const isAndroid = Platform.OS === "android"; const scrollViewRef = useAnimatedRef(); const scrollOffsetY = useSharedValue(0); const animatedOffsetY = useSharedValue(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(() => { "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 (