--- name: expo-chat-ui description: Build a production-quality chat UI with keyboard handling, recycling lists, gestures, and platform-specific implementations --- ## Overview This skill covers building a performant chat UI in Expo with: - Smooth keyboard animations with interactive dismissal - High-performance recycling list for messages - Safe area and inset management - Platform-specific implementations (native SwiftUI / web HTML) - Image attachments and speech recognition ## Required Dependencies ```bash npx expo install react-native-reanimated react-native-safe-area-context react-native-keyboard-controller @legendapp/list @react-native-masked-view/masked-view expo-haptics expo-speech-recognition expo-glass-effect @expo/ui ``` ## Architecture ``` components/ ├── keyboard-scroll-view.tsx # ChatView wrapper with keyboard handling ├── measure-node.tsx # Height measurement context ├── composer/ │ ├── composer.tsx # Native implementation │ ├── composer.web.tsx # Web implementation │ ├── composer-text-input.tsx # Text input with keyboard integration │ ├── send-button.tsx # Send/Mic/Stop button states │ ├── multi-button.tsx # Context menu for attachments │ └── selected-media-context.tsx # Image attachment state └── lib/ ├── haptic.ts # Native haptics └── haptic.web.ts # Web haptics (no-op) ``` ## Core Components ### 1. ChatView Wrapper The `ChatView` component provides context for measuring toolbar height and coordinating keyboard animations. ```tsx import { MeasureNode } from "./measure-node"; export function ChatView(props: { children?: React.ReactNode }) { return {props.children}; } // Sub-components attached to ChatView ChatView.Toolbar = ChatViewToolbar; ChatView.ScrollView = ChatScrollView; ``` ### 2. MeasureNode Context Tracks dynamic heights using shared values for smooth animations: ```tsx import { SharedValue, useSharedValue } from "react-native-reanimated"; import { View } from "react-native"; const MeasureNodeContext = React.createContext<{ uiHeight: SharedValue; } | null>(null); export function MeasureNode(props: { children?: React.ReactNode }) { const uiHeight = useSharedValue(0); return ( {props.children} ); } MeasureNode.Trigger = function Trigger(props: { children?: React.ReactNode }) { const ref = useRef(null); const { uiHeight } = React.use(MeasureNodeContext); useLayoutEffect(() => { if (!ref.current) return; const rect = ref.current.getBoundingClientRect(); uiHeight.value = rect.height; }, [ref, uiHeight]); const onLayout = ({ nativeEvent: { layout } }) => { uiHeight.set(layout.height); }; return ; }; MeasureNode.useHeightSharedValue = function useFrame() { const context = React.use(MeasureNodeContext); return context.uiHeight; }; ``` ## Keyboard Handling ### useKeyboardAnimation Hook Handles all keyboard lifecycle events for smooth animations: ```tsx import { useReanimatedKeyboardAnimation, useKeyboardHandler, } from "react-native-keyboard-controller"; import { useSharedValue, useAnimatedScrollHandler, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; const useKeyboardAnimation = () => { const progress = useSharedValue(0); const height = useSharedValue(0); const inset = useSharedValue(0); const offset = useSharedValue(0); const scroll = useSharedValue(0); const shouldUseOnMoveHandler = useSharedValue(false); const { bottom } = useSafeAreaInsets(); useKeyboardHandler({ onStart: (e) => { "worklet"; // Handle interactive gesture detection if (progress.value !== 1 && progress.value !== 0 && e.height !== 0) { shouldUseOnMoveHandler.value = true; return; } progress.value = e.progress; height.value = e.height; inset.value = Math.max(bottom, height.value); offset.value = Math.max(Math.max(height.value, bottom) + scroll.value, 0); }, onInteractive: (e) => { "worklet"; progress.value = e.progress; height.value = e.height; }, onMove: (e) => { "worklet"; if (shouldUseOnMoveHandler.value) { progress.value = e.progress; height.value = e.height; } }, onEnd: (e) => { "worklet"; height.value = e.height; progress.value = e.progress; shouldUseOnMoveHandler.value = false; }, }); const isCloseToEnd = useSharedValue(true); const onScroll = useAnimatedScrollHandler( { onScroll: (e) => { scroll.value = e.contentOffset.y - inset.value; }, }, [] ); return { height, onScroll, inset, offset, scroll, isCloseToEnd }; }; ``` ### Toolbar Positioning Position the composer toolbar above the keyboard: ```tsx function ChatViewToolbarPadding( props: React.ComponentProps ) { const { bottom: paddingBottom } = useSafeAreaInsets(); const { height, progress } = useReanimatedKeyboardAnimation(); const keyboard = useAnimatedKeyboard(); const toolbarHeightSharedValue = MeasureNode.useHeightSharedValue(); const bottomStyle = useAnimatedStyle(() => { const absMax = progress.value === 1 ? Math.max(Math.abs(height.value), keyboard.height.value) : Math.abs(height.value); return { bottom: Math.max(paddingBottom, absMax), }; }, [paddingBottom, toolbarHeightSharedValue, height, keyboard, progress]); return ( ); } ChatView.Toolbar = function ChatViewToolbar({ children, ...props }: React.ComponentProps) { return ( {children} ); }; ``` ## Recycling List ### ChatScrollView with Legend List High-performance list using `@legendapp/list`: ```tsx import { LegendList, LegendListProps, LegendListRef } from "@legendapp/list"; const AnimatedLegendListComponent = Animated.createAnimatedComponent(LegendList); export type ChatScrollViewRef = AnimatedRef; export type ChatScrollViewProps = LegendListProps & { ref: ChatScrollViewRef; }; function ChatScrollView({ children, ref, ...props }: ChatScrollViewProps) { const toolbarHeightSharedValue = MeasureNode.useHeightSharedValue(); const scrollViewContentHeight = useSharedValue(0); const { height } = useReanimatedKeyboardAnimation(); const { bottom } = useSafeAreaInsets(); const isTouching = useSharedValue(false); const scrollViewHeight = useSharedValue(0); const controllerProps = useKeyboardAnimation(); // Custom ref with requestScrollToEnd useImperativeHandle( ref, () => ({ ...ref?.current, requestScrollToEnd: (animated: boolean) => { if (isTouching.value) return; // Skip if user is touching ref.current?.scrollToEnd({ animated }); }, }), [ref] ); // Scroll indicator insets const scrollIndicatorInsets = useDerivedValue( () => ({ top: 56, // composer header height bottom: Math.max(0, Math.abs(height.value) - bottom) + toolbarHeightSharedValue.value, }), [toolbarHeightSharedValue, bottom, height] ); // Animated content insets const animatedProps = useAnimatedProps(() => ({ contentInset: { bottom: Math.abs(controllerProps.inset.value) + toolbarHeightSharedValue.value, }, contentOffset: { x: 0, y: controllerProps.offset.value, }, })); return ( { isTouching.value = true; props.onScrollBeginDrag?.(e); }} onScrollEndDrag={(e) => { isTouching.value = false; props.onScrollEndDrag?.(e); }} onScroll={controllerProps.onScroll} keyboardDismissMode="interactive" contentInsetAdjustmentBehavior="never" onContentSizeChange={(width: number, height: number) => { scrollViewContentHeight.value = height; props.onContentSizeChange?.(width, height); }} onLayout={(e) => { scrollViewHeight.value = e.nativeEvent.layout.height; props.onLayout?.(e); }} /> ); } ``` ### Usage ```tsx ref={scrollViewRef} keyboardShouldPersistTaps="handled" contentContainerStyle={{ gap: 16, paddingTop: 72, alignItems: "stretch", }} maintainScrollAtEnd={isThinking} maintainVisibleContentPosition={isThinking} estimatedItemSize={32} initialScrollIndex={messages.length - 1} keyExtractor={(item) => item?.id} recycleItems={true} data={messages} renderItem={({ item }) => } ListFooterComponent={() => error ? : null } /> ``` ## Composer Text Input ### Native Implementation with SwiftUI ```tsx import * as SUI from "@expo/ui/swift-ui"; import * as mod from "@expo/ui/swift-ui/modifiers"; import { ChatView } from "@/components/keyboard-scroll-view"; export function ComposerTextInput({ authenticated, canSend, scrollViewRef, textInputRef, onSend, onChangeText, setTextInput, }: Props) { return ( { if (focus) { scrollViewRef?.current?.scrollToOffset({ offset: Number.MAX_SAFE_INTEGER - 1, animated: true, }); } }} onChangeText={onChangeText} onSubmit={onSend} autoFocus={authenticated} modifiers={[ mod.disabled(!authenticated), mod.padding({ leading: 12, top: 8, bottom: 8, trailing: 48 }), mod.frame({ minHeight: 44, maxHeight: 200, idealWidth: 10000, }), mod.clipShape("roundedRectangle"), ]} /> ); } ``` ## Send Button with Speech Recognition Three-state button: recording, ready to send, or thinking: ```tsx import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent, } from "expo-speech-recognition"; import { haptics } from "@/lib/haptic"; export function SendButton({ disabled, canSend, onSend, setTextInput, textInputText, }) { const isThinking = useIsAgentThinking(); const { stop } = useAgentActions(); const [isRecording, setIsRecording] = useState(false); useSpeechRecognitionEvent("start", () => setIsRecording(true)); useSpeechRecognitionEvent("end", () => { setIsRecording(false); const text = textInputText?.current?.trim(); if (text) { haptics.success(); setTextInput(text, true); } }); useSpeechRecognitionEvent("result", (event) => { const text = event.results[0]?.transcript || ""; if (text) setTextInput(text, false); }); // Stop button when AI is thinking if (isThinking) { return ( ); } // Mic button when no text / recording if (isRecording || !canSend) { return ( { if (isRecording) { ExpoSpeechRecognitionModule.stop(); return; } haptics.impact("medium"); const permission = await ExpoSpeechRecognitionModule.requestSpeechRecognizerPermissionsAsync(); if (permission.status === "denied") { alert("Permission denied. Enable in App Settings."); return; } ExpoSpeechRecognitionModule.start({ interimResults: true, continuous: false, addsPunctuation: true, }); }} > ); } // Send button return ( onSend(textInputText?.current)} > ); } ``` ## Image Attachments ### SelectedMediaContext ```tsx interface SelectedImage { url: string; } const SelectedMediaContext = createContext(undefined); export function SelectedMediaProvider({ children }) { const [selectedImages, setSelectedImages] = useState([]); const addImages = (images: SelectedImage[]) => { setSelectedImages((current) => { const combined = [...current, ...images]; const unique = Array.from( new Map(combined.map((item) => [item.url, item])).values() ); return unique.slice(-3); // Keep only last 3 }); }; const removeImage = (index: number) => { setSelectedImages((current) => current.filter((_, i) => i !== index)); }; return ( {children} ); } ``` ### Image Preview ```tsx function ImagePreviewSection() { const { selectedImages, removeImage } = useSelectedMedia(); return ( ( removeImage(index)} className="absolute -top-1 -right-1 w-6 h-6 rounded-full bg-black/80" > )} /> ); } ``` ## Haptic Feedback ### Native (haptic.ts) ```tsx import * as Haptics from "expo-haptics"; export const haptics = { impact( type: | "success" | "warning" | "error" | "select" | "light" | "medium" | "heavy" ) { if (type === "success" || type === "warning" || type === "error") { return Haptics.notificationAsync(type); } if (type === "select") { return Haptics.selectionAsync(); } return Haptics.impactAsync(type); }, success: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success), warning: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning), error: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error), }; ``` ### Web (haptic.web.ts) ```tsx export const haptics = { impact: () => {}, success: () => {}, warning: () => {}, error: () => {}, }; ``` ## Full Composer Usage ```tsx export function FullComposer() { return ( ); } function Composer() { const { sendMessageAsync } = useAgentActions(); const { messages, error } = useAgentState(); const isThinking = useIsAgentThinking(); const textInputRef = useRef(null); const scrollViewRef = useRef(null); const { selectedImages, clearImages } = useSelectedMedia(); const sendMessageWrapper = async (text: string) => { if (!text.trim()) return; textInputRef.current?.setText(""); clearImages(); sendMessageAsync({ text, images: selectedImages.length ? selectedImages : undefined, }); }; return ( } > } /> ); } ``` ## Key Props Reference ### ChatView.ScrollView Props | Prop | Type | Description | | -------------------------------- | --------------- | ----------------------------------------- | | `keyboardShouldPersistTaps` | `"handled"` | Allow taps on buttons while keyboard open | | `keyboardDismissMode` | `"interactive"` | Swipe-to-dismiss keyboard | | `maintainScrollAtEnd` | `boolean` | Auto-scroll when AI is responding | | `maintainVisibleContentPosition` | `boolean` | Keep position when content updates | | `recycleItems` | `true` | Enable view recycling for performance | | `estimatedItemSize` | `number` | Average item height for layout | | `contentInsetAdjustmentBehavior` | `"never"` | Disable automatic safe area handling | ### Keyboard Animation Shared Values | Value | Type | Description | | ---------- | -------- | ------------------------------------------ | | `progress` | `0-1` | Keyboard show/hide animation progress | | `height` | `number` | Current keyboard height in pixels | | `inset` | `number` | Max of bottom safe area or keyboard height | | `offset` | `number` | Cumulative offset for scroll positioning | | `scroll` | `number` | Current scroll position relative to inset | ## Platform-Specific Files Use file suffixes for platform-specific implementations: - `composer.tsx` - Native implementation with SwiftUI - `composer.web.tsx` - Web implementation with HTML/radix - `multi-button.tsx` - Native context menu - `multi-button.web.tsx` - Web dropdown menu - `haptic.ts` - Native haptics - `haptic.web.ts` - Web haptics (no-op) ## Tips 1. **Always use `keyboardShouldPersistTaps="handled"`** to allow button interactions while keyboard is open 2. **Track `isTouching` state** to prevent auto-scroll while user is manually scrolling 3. **Use `useImperativeHandle`** to extend refs with custom methods like `requestScrollToEnd` 4. **Worklet functions** must include the `"worklet"` directive for keyboard handlers 5. **Use `useDerivedValue`** for computed animations that depend on multiple shared values 6. **Set `estimatedItemSize`** based on your average message height for optimal recycling