mirror of
https://github.com/zoriya/legend-list.git
synced 2026-08-15 18:44:30 +00:00
fix up KeyboardAvoidingLegendList to support KeyboardGestureArea and improve interactive
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user