Token导航 LogoToken导航TokenDH.com
前端设计权限需确认github未标认证来源可访问许可证需确认审计通过

expo-chat-ui世博会聊天界面

Agent Skill

用于辅助前端页面、组件、样式和交互逻辑的开发与维护。它适合让 Agent 生成或审查 React、Next.js、Vue、Tailwind、CSS 等相关代码,整理组件结构,或定位布局和性能问题。使用时需要结合项目现有设计系统、路由和构建方式,避免只生成孤立片段;涉及页面改动时,应配合本地预览和构建检查确认视觉效果。

总安装

242

周安装

10

GitHub Stars

209

下载量

79
CodexClaudeCursorGemini CLI

安装说明

本站只整理中文说明和来源信息,不托管安装包,也不代用户安装。

GitHub

来源数

2

许可证

unknown

最后核验

2026-05-01

来源状态

来源可访问

安装方式

通过对话安装

复制提示词发给支持本地命令或 Skills 的 AI 助手,先确认命令和权限,再让它执行。

请帮我安装这个 Agent Skill:expo-chat-ui(世博会聊天界面)
来源仓库:https://github.com/evanbacon/evanbacon.dev
仓库路径:skills/expo-chat-ui
安装命令:
npx skills add https://github.com/evanbacon/evanbacon.dev --skill expo-chat-ui
安装前请先检查当前环境是否支持对应 CLI,并向我确认将要执行的命令、安装目录、联网范围和文件读写权限;确认后再执行。

命令行安装

复制命令到本机终端执行。该命令会通过 npx skills 从第三方来源获取 Skill;本站只展示命令,不托管安装包,也不自动执行。

skills.shnpx skills
npx skills add https://github.com/evanbacon/evanbacon.dev --skill expo-chat-ui

简介

构建高性能聊天界面,支持消息回收列表与键盘交互动画。

  • 提供平台特定实现(SwiftUI/Web HTML),集成图像附件与语音识别能力。
  • 基于 Legend List 优化渲染性能,配合 Haptics 与玻璃效果增强用户体验。
  • 需安装多个依赖包并正确配置安全区域与键盘控制器方可运行。
  • expo-chat-ui 属于前端设计类 Skill,可作为该场景下的辅助能力补充。

SKILL.md

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

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.

import { MeasureNode } from "./measure-node";

export function ChatView(props: { children?: React.ReactNode }) {
  return <MeasureNode>{props.children}</MeasureNode>;
}

// Sub-components attached to ChatView
ChatView.Toolbar = ChatViewToolbar;
ChatView.ScrollView = ChatScrollView;

2. MeasureNode Context

Tracks dynamic heights using shared values for smooth animations:

import { SharedValue, useSharedValue } from "react-native-reanimated";
import { View } from "react-native";

const MeasureNodeContext = React.createContext<{
  uiHeight: SharedValue<number>;
} | null>(null);

export function MeasureNode(props: { children?: React.ReactNode }) {
  const uiHeight = useSharedValue(0);
  return (
    <MeasureNodeContext value={{ uiHeight }}>
      {props.children}
    </MeasureNodeContext>
  );
}

MeasureNode.Trigger = function Trigger(props: { children?: React.ReactNode }) {
  const ref = useRef<View>(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 <View {...props} ref={ref} onLayout={onLayout} />;
};

MeasureNode.useHeightSharedValue = function useFrame() {
  const context = React.use(MeasureNodeContext);
  return context.uiHeight;
};

Keyboard Handling

useKeyboardAnimation Hook

Handles all keyboard lifecycle events for smooth animations:

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:

function ChatViewToolbarPadding(
  props: React.ComponentProps<typeof Animated.View>
) {
  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 (
    <Animated.View
      {...props}
      style={[{ position: "absolute" }, bottomStyle, props.style]}
      pointerEvents="box-none"
    />
  );
}

ChatView.Toolbar = function ChatViewToolbar({
  children,
  ...props
}: React.ComponentProps<typeof Animated.View>) {
  return (
    <ChatViewToolbarPadding {...props}>
      <MeasureNode.Trigger>{children}</MeasureNode.Trigger>
    </ChatViewToolbarPadding>
  );
};

Recycling List

ChatScrollView with Legend List

High-performance list using @legendapp/list:

import { LegendList, LegendListProps, LegendListRef } from "@legendapp/list";

const AnimatedLegendListComponent =
  Animated.createAnimatedComponent(LegendList);

export type ChatScrollViewRef = AnimatedRef<LegendListRef>;
export type ChatScrollViewProps<T = any> = LegendListProps<T> & {
  ref: ChatScrollViewRef;
};

function ChatScrollView<T>({
  children,
  ref,
  ...props
}: ChatScrollViewProps<T>) {
  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 (
    <AnimatedLegendListComponent
      {...props}
      ref={ref}
      animatedProps={animatedProps}
      scrollIndicatorInsets={scrollIndicatorInsets}
      scrollEventThrottle={16}
      onScrollBeginDrag={(e) => {
        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

<ChatView.ScrollView<MessageType>
  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 }) => <Message key={item.id} message={item} />}
  ListFooterComponent={() =>
    error ? <ChatErrorMessage error={error} /> : null
  }
/>

Composer Text Input

Native Implementation with SwiftUI

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 (
    <ChatView.Toolbar style={{ width: "100%" }}>
      <Host
        style={{ minWidth: "100%", maxWidth: "100%" }}
        matchContents={{ vertical: true }}
      >
        <SUI.GlassEffectContainer
          spacing={4}
          modifiers={[
            mod.disabled(!authenticated),
            frame({ idealWidth: 11111 }),
            ignoreSafeArea({ regions: "all", edges: "all" }),
          ]}
        >
          <SUI.HStack spacing={6} alignment="bottom">
            <MultiButton disabled={!authenticated} />

            <SUI.ZStack
              modifiers={[
                glassEffectId("composer", "container"),
                glassEffect({
                  glass: { variant: "regular", interactive: authenticated },
                  shape: "roundedRectangle",
                  cornerRadius: 24,
                }),
              ]}
              alignment="bottom"
            >
              <SUI.TextField
                multiline
                placeholder="Generate code..."
                allowNewlines={false}
                numberOfLines={6}
                ref={textInputRef}
                onChangeFocus={(focus) => {
                  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"),
                ]}
              />
              <SendButton canSend={canSend} onSend={onSend} />
            </SUI.ZStack>
          </SUI.HStack>
        </SUI.GlassEffectContainer>
      </Host>
    </ChatView.Toolbar>
  );
}

Send Button with Speech Recognition

Three-state button: recording, ready to send, or thinking:

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 (
      <SUI.Button variant="borderedProminent" onPress={stop}>
        <SUI.Image systemName="stop.fill" />
      </SUI.Button>
    );
  }

  // Mic button when no text / recording
  if (isRecording || !canSend) {
    return (
      <SUI.Button
        variant="borderedProminent"
        color={isRecording ? AC.systemRed : AC.label}
        onPress={async () => {
          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,
          });
        }}
      >
        <SUI.Image systemName={isRecording ? "stop.fill" : "mic.fill"} />
      </SUI.Button>
    );
  }

  // Send button
  return (
    <SUI.Button
      variant="borderedProminent"
      onPress={() => onSend(textInputText?.current)}
    >
      <SUI.Image systemName="arrow.up" />
    </SUI.Button>
  );
}

Image Attachments

SelectedMediaContext

interface SelectedImage {
  url: string;
}

const SelectedMediaContext =
  createContext<SelectedMediaContextValue>(undefined);

export function SelectedMediaProvider({ children }) {
  const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);

  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 (
    <SelectedMediaContext
      value={{ selectedImages, addImages, removeImage, clearImages, setImages }}
    >
      {children}
    </SelectedMediaContext>
  );
}

Image Preview

function ImagePreviewSection() {
  const { selectedImages, removeImage } = useSelectedMedia();

  return (
    <AnimatedFlatList
      horizontal
      entering={FadeIn}
      exiting={FadeOut}
      itemLayoutAnimation={LinearTransition}
      keyboardShouldPersistTaps="handled"
      contentContainerClassName="pt-2 px-3 gap-2"
      data={selectedImages}
      renderItem={({ item, index }) => (
        <AnimatedView entering={FadeIn} exiting={FadeOut}>
          <Image source={{ uri: item.url }} className="w-15 h-15 rounded-lg" />
          <Pressable
            onPress={() => removeImage(index)}
            className="absolute -top-1 -right-1 w-6 h-6 rounded-full bg-black/80"
          >
            <SFIcon name="xmark" className="text-white" />
          </Pressable>
        </AnimatedView>
      )}
    />
  );
}

Haptic Feedback

Native (haptic.ts)

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)

export const haptics = {
  impact: () => {},
  success: () => {},
  warning: () => {},
  error: () => {},
};

Full Composer Usage

export function FullComposer() {
  return (
    <SelectedMediaProvider>
      <View className="flex-1">
        <ComposerModalBackdrop />
        <Composer />
      </View>
    </SelectedMediaProvider>
  );
}

function Composer() {
  const { sendMessageAsync } = useAgentActions();
  const { messages, error } = useAgentState();
  const isThinking = useIsAgentThinking();
  const textInputRef = useRef<SUI.TextFieldRef>(null);
  const scrollViewRef = useRef<ChatScrollViewRef>(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 (
    <View className="flex-1">
      <ComposerHeader />

      <ChatView>
        <MaskedView
          style={{ flex: 1 }}
          maskElement={
            <View
              style={{
                flex: 1,
                experimental_backgroundImage:
                  "linear-gradient(180deg,rgba(0,0,0,0) 0%, rgba(0,0,0,1) 80px)",
              }}
            />
          }
        >
          <ChatView.ScrollView
            ref={scrollViewRef}
            keyboardShouldPersistTaps="handled"
            maintainScrollAtEnd={isThinking}
            maintainVisibleContentPosition={isThinking}
            estimatedItemSize={32}
            recycleItems={true}
            data={messages}
            renderItem={({ item }) => <Message message={item} />}
          />
        </MaskedView>

        <ComposerTextInput
          scrollViewRef={scrollViewRef}
          textInputRef={textInputRef}
          onSend={sendMessageWrapper}
        />
      </ChatView>
    </View>
  );
}

Key Props Reference

ChatView.ScrollView Props

PropTypeDescription
keyboardShouldPersistTaps"handled"Allow taps on buttons while keyboard open
keyboardDismissMode"interactive"Swipe-to-dismiss keyboard
maintainScrollAtEndbooleanAuto-scroll when AI is responding
maintainVisibleContentPositionbooleanKeep position when content updates
recycleItemstrueEnable view recycling for performance
estimatedItemSizenumberAverage item height for layout
contentInsetAdjustmentBehavior"never"Disable automatic safe area handling

Keyboard Animation Shared Values

ValueTypeDescription
progress0-1Keyboard show/hide animation progress
heightnumberCurrent keyboard height in pixels
insetnumberMax of bottom safe area or keyboard height
offsetnumberCumulative offset for scroll positioning
scrollnumberCurrent 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

适合场景

01

用户想查找某类 Agent Skill 时

02

需要根据任务场景推荐可安装能力包时

03

需要对比不同来源的安装命令和来源信息时

能力概览

能力 1

按任务关键词查找相关 Skills

能力 2

展示可复制的安装命令

能力 3

保留来源站点、仓库和原始说明,方便继续核验

能力 4

展示第三方安全扫描或审计结果

安装后应在对应宿主中按原始 README 的触发条件使用;具体调用方式请以来源页面和 README 为准。

平台分布

Codex

35.59%
按下载量换算28

Claude

30.09%
按下载量换算24

Cursor

20.04%
按下载量换算16

Gemini CLI

9.69%
按下载量换算8

安全审计

Gen Agent Trust Hub

通过

Socket

通过

Snyk

通过

权限和风险

权限需确认

当前来源未能明确判断权限范围,默认进入异常复核队列。

安装前确认

本站仅展示第三方公开信息,不托管安装包,不提供自动安装或运行环境。安装前应自行审查源码、依赖和命令行为。当前只有一个来源,正式发布前建议补源仓库或其他目录站核验。

来源信息

继续浏览同类 Skills