From c62dd98296cd2cce1a17ee22b459a44564e3403c Mon Sep 17 00:00:00 2001 From: Tyler Coffman Date: Fri, 13 Feb 2026 09:00:49 -0800 Subject: [PATCH] Chat keyboard overlap example. This is an example of how to make it so that messages in your chat will be visible behind the input box when you scroll up in the chat. --- example/app/(tabs)/index.tsx | 184 ++++++------- example/app/chat-keyboard-overlap/index.tsx | 275 ++++++++++++++++++++ 2 files changed, 369 insertions(+), 90 deletions(-) create mode 100644 example/app/chat-keyboard-overlap/index.tsx diff --git a/example/app/(tabs)/index.tsx b/example/app/(tabs)/index.tsx index 4afd5fe3..08f8c1bf 100644 --- a/example/app/(tabs)/index.tsx +++ b/example/app/(tabs)/index.tsx @@ -1,164 +1,168 @@ -import { Link, type LinkProps } from "expo-router"; -import { useCallback } from "react"; -import { type LayoutChangeEvent, Platform, Pressable, StyleSheet, useColorScheme, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; +import { Link, type LinkProps } from 'expo-router'; +import { useCallback } from 'react'; +import { type LayoutChangeEvent, Platform, Pressable, StyleSheet, useColorScheme, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; -import { LegendList } from "@legendapp/list"; -import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; -import { ThemedText } from "~/components/ThemedText"; -import { ThemedView } from "~/components/ThemedView"; +import { LegendList } from '@legendapp/list'; +import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; +import { ThemedText } from '~/components/ThemedText'; +import { ThemedView } from '~/components/ThemedView'; // @ts-expect-error nativeFabricUIManager is not defined in the global object types -export const IsNewArchitecture = Platform.OS === "web" || global.nativeFabricUIManager != null; +export const IsNewArchitecture = Platform.OS === 'web' || global.nativeFabricUIManager != null; type ListElement = { id: number; title: string; - url: LinkProps["href"]; + url: LinkProps['href']; index: number; }; const data: ListElement[] = [ { - title: "Bidirectional Infinite List", - url: "/bidirectional-infinite-list", + title: 'Bidirectional Infinite List', + url: '/bidirectional-infinite-list', }, { - title: "Chat example", - url: "/chat-example", + title: 'Chat example', + url: '/chat-example', }, { - title: "AI Chat", - url: "/ai-chat", + title: 'AI Chat', + url: '/ai-chat', }, { - title: "Infinite chat", - url: "/chat-infinite", + title: 'Infinite chat', + url: '/chat-infinite', }, { - title: "Countries List", - url: "/countries", + title: 'Countries List', + url: '/countries', }, { - title: "Countries with headers", - url: "/countries-with-headers", + title: 'Countries with headers', + url: '/countries-with-headers', }, { - title: "Countries with headers fixed", - url: "/countries-with-headers-fixed", + title: 'Countries with headers fixed', + url: '/countries-with-headers-fixed', }, { - title: "Countries with headers sticky", - url: "/countries-with-headers-sticky", + title: 'Countries with headers sticky', + url: '/countries-with-headers-sticky', }, { - title: "Lazy List", - url: "/lazy-list", + title: 'Lazy List', + url: '/lazy-list', }, { - title: "Always render", - url: "/always-render", + title: 'Always render', + url: '/always-render', }, { - title: "MVCP test", - url: "/mvcp-test", + title: 'MVCP test', + url: '/mvcp-test', }, { - title: "Accurate scrollToIndex", - url: "/accurate-scrollto", + title: 'Accurate scrollToIndex', + url: '/accurate-scrollto', }, { - title: "Accurate scrollToIndex 2", - url: "/accurate-scrollto-2", + title: 'Accurate scrollToIndex 2', + url: '/accurate-scrollto-2', }, { - title: "Columns", - url: "/columns", + title: 'Columns', + url: '/columns', }, { - title: "Product shelf", - url: "/product-shelf", + title: 'Product shelf', + url: '/product-shelf', }, { - title: "Cards Columns", - url: "/cards-columns", + title: 'Cards Columns', + url: '/cards-columns', }, { - title: "Chat keyboard", - url: "/chat-keyboard", + title: 'Chat keyboard', + url: '/chat-keyboard', }, { - title: "Movies FlashList", - url: "/movies-flashlist", + title: 'Chat keyboard overlap', + url: '/chat-keyboard-overlap', }, { - title: "Initial scroll index precise navigation", - url: "/initial-scroll-index", + title: 'Movies FlashList', + url: '/movies-flashlist', }, { - title: "Initial scroll index(free element height)", - url: "/initial-scroll-index-free-height", + title: 'Initial scroll index precise navigation', + url: '/initial-scroll-index', }, { - title: "Initial scroll index(start at the end)", - url: "/initial-scroll-start-at-the-end", + title: 'Initial scroll index(free element height)', + url: '/initial-scroll-index-free-height', }, { - title: "Initial Scroll Index keyed", - url: "/initial-scroll-index-keyed", + title: 'Initial scroll index(start at the end)', + url: '/initial-scroll-start-at-the-end', }, { - title: "Mutable elements", - url: "/mutable-cells", + title: 'Initial Scroll Index keyed', + url: '/initial-scroll-index-keyed', }, { - title: "Extra data", - url: "/extra-data", + title: 'Mutable elements', + url: '/mutable-cells', }, { - title: "Countries List(FlashList)", - url: "/countries-flashlist", + title: 'Extra data', + url: '/extra-data', }, { - title: "Filter elements", - url: "/filter-elements", + title: 'Countries List(FlashList)', + url: '/countries-flashlist', }, { - title: "Video feed", - url: "/video-feed", + title: 'Filter elements', + url: '/filter-elements', }, { - title: "Countries Reorder", - url: "/countries-reorder", + title: 'Video feed', + url: '/video-feed', }, { - title: "Cards FlashList", - url: "/cards-flashlist", + title: 'Countries Reorder', + url: '/countries-reorder', }, { - title: "Cards no recycle", - url: "/cards-no-recycle", + title: 'Cards FlashList', + url: '/cards-flashlist', }, { - title: "Cards FlatList", - url: "/cards-flatlist", + title: 'Cards no recycle', + url: '/cards-no-recycle', }, { - title: "Add to the end", - url: "/add-to-end", + title: 'Cards FlatList', + url: '/cards-flatlist', }, { - title: "Chat resize outer", - url: "/chat-resize-outer", + title: 'Add to the end', + url: '/add-to-end', }, { - title: "Accurate scrollToHuge", - url: "/accurate-scrollto-huge", + title: 'Chat resize outer', + url: '/chat-resize-outer', }, { - title: "Chat keyboard big", - url: "/chat-keyboard-big", + title: 'Accurate scrollToHuge', + url: '/accurate-scrollto-huge', + }, + { + title: 'Chat keyboard big', + url: '/chat-keyboard-big', }, ].map( (v, i) => @@ -171,7 +175,7 @@ const data: ListElement[] = [ const RightIcon = () => ; const ListItem = ({ title, url, index }: ListElement) => { - const theme = useColorScheme() ?? "light"; + const theme = useColorScheme() ?? 'light'; return ( @@ -179,7 +183,7 @@ const ListItem = ({ title, url, index }: ListElement) => { @@ -194,7 +198,7 @@ const ListItem = ({ title, url, index }: ListElement) => { const ListElements = () => { const height = useBottomTabBarHeight(); const onLayout = useCallback((event: LayoutChangeEvent) => { - console.log("onlayout", event.nativeEvent.layout); + console.log('onlayout', event.nativeEvent.layout); }, []); return ( @@ -203,16 +207,16 @@ const ListElements = () => { estimatedItemSize={60} keyExtractor={(item) => item.id.toString()} ListFooterComponent={} - ListFooterComponentStyle={{ height: Platform.OS === "ios" ? height : 0 }} + ListFooterComponentStyle={{ height: Platform.OS === 'ios' ? height : 0 }} ListHeaderComponent={ - - {IsNewArchitecture ? "New" : "Old"} Architecture, {__DEV__ ? "DEV" : "PROD"}, 2.0 + + {IsNewArchitecture ? 'New' : 'Old'} Architecture, {__DEV__ ? 'DEV' : 'PROD'}, 2.0 } onItemSizeChanged={(info) => { - console.log("item size changed", info); + console.log('item size changed', info); }} onLayout={onLayout} renderItem={({ item, index }) => } @@ -227,11 +231,11 @@ const styles = StyleSheet.create({ }, item: { borderBottomWidth: 1, - flexDirection: "row", + flexDirection: 'row', height: 60, - justifyContent: "space-between", + justifyContent: 'space-between', padding: 16, - width: "100%", + width: '100%', }, }); diff --git a/example/app/chat-keyboard-overlap/index.tsx b/example/app/chat-keyboard-overlap/index.tsx new file mode 100644 index 00000000..db19b142 --- /dev/null +++ b/example/app/chat-keyboard-overlap/index.tsx @@ -0,0 +1,275 @@ +import { type PropsWithChildren, useMemo, useState } from 'react'; +import { BlurView } from 'expo-blur'; +import { Button, type LayoutChangeEvent, 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'; + +type Message = { + id: string; + text: string; + sender: 'user' | 'bot'; + timeStamp: number; +}; + +let idCounter = 0; +const MS_PER_SECOND = 1000; + +const defaultChatMessages: Message[] = [ + { + id: String(idCounter++), + sender: 'user', + text: 'Hi, I have a question about your product', + timeStamp: Date.now() - MS_PER_SECOND * 5, + }, + { + id: String(idCounter++), + sender: 'bot', + text: 'Hello there! How can I assist you today?', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'user', + text: "I'm looking for information about pricing plans", + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'bot', + text: 'We offer several pricing tiers based on your needs', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'bot', + text: 'Our basic plan starts at $9.99 per month', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'user', + text: 'Do you offer any discounts for annual billing?', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'bot', + text: 'Yes! You can save 20% with our annual billing option', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'user', + text: 'That sounds great. What features are included?', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'bot', + text: 'The basic plan includes all core features plus 10GB storage', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'bot', + text: 'Premium plans include priority support and additional tools', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'user', + text: 'I think the basic plan would work for my needs', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'bot', + text: 'Perfect! I can help you get set up with that', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'user', + text: 'Thanks for your help so far', + timeStamp: Date.now() - MS_PER_SECOND * 4, + }, + { + id: String(idCounter++), + sender: 'bot', + text: "You're welcome! Is there anything else I can assist with today?", + timeStamp: Date.now() - MS_PER_SECOND * 3, + }, +]; + +function ChatMessage({ item }: { item: Message }) { + return ( + <> + + {item.text} + + + {new Date(item.timeStamp).toLocaleTimeString()} + + + ); +} + +const ChatKeyboard = () => { + const [messages, setMessages] = useState(defaultChatMessages); + const [inputText, setInputText] = useState(''); + const [inputHeight, setInputHeight] = useState(0); // Default estimate + const insets = useSafeAreaInsets(); + + const handleInputLayout = (event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + setInputHeight(height); + }; + + const contentContainerStyle = useMemo( + () => [styles.contentContainer, { paddingBottom: inputHeight }], + [inputHeight], + ); + + const inputContainerStyle = useMemo( + () => [styles.inputContainer, { paddingBottom: insets.bottom + 10, marginTop: -inputHeight }], + [inputHeight], + ); + + const sendMessage = () => { + const text = inputText || 'Empty message'; + if (text.trim()) { + setMessages((messagesNew) => [ + ...messagesNew, + { id: String(idCounter++), sender: 'user', text: text, timeStamp: Date.now() }, + ]); + setInputText(''); + setTimeout(() => { + setMessages((messagesNew) => [ + ...messagesNew, + { + id: String(idCounter++), + sender: 'bot', + text: `Answer: ${text.toUpperCase()}`, + timeStamp: Date.now(), + }, + ]); + }, 300); + } + }; + + return ( + + + + {inputHeight !== 0 && ( + item.id} + maintainScrollAtEnd + maintainVisibleContentPosition + renderItem={ChatMessage} + safeAreaInsetBottom={insets.bottom} + style={styles.list} + /> + )} + + + + +