Token导航 LogoToken导航

Agent Skill · 前端设计

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

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

expo-chat-ui 是前端设计类 Agent Skill,可用宿主为 Codex、Claude、Cursor、Gemini CLI,通过GitHub 仓库获取,权限为权限未标注;许可证信息还不明确,第三方扫描已通过,按安装量在前端设计类的 10,216 个 Skill 里排第 9,031。

总安装 242周安装 10GitHub Stars 209下载 79核验 2026-05-01

安装说明

本站只整理中文说明,不托管安装包,也不代用户安装。
GitHub
许可证
unknown

安装方式

通过对话安装

复制提示词发给支持本地命令或 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

这个 Skill 是做什么的

前端设计类 Skill 关注页面结构、视觉规范和组件写法,输出通常是可运行的界面代码。

构建高性能聊天界面,支持消息回收列表与键盘交互动画。提供平台特定实现(SwiftUI/Web HTML),集成图像附件与语音识别能力。基于 Legend List 优化渲染性能,配合 Haptics 与玻璃效果增强用户体验。需安装多个依赖包并正确配置安全区域与键盘控制器方可运行。

expo-chat-ui 属于前端设计类 Skill,可作为该场景下的辅助能力补充。

来源仓库内路径:skills/expo-chat-ui

上手步骤

4 步
  1. 1

    确认自己的宿主工具

    公开资料标注支持 Codex、Claude、Cursor、Gemini CLI;先确认自己用的是其中之一,再按对应工具的 Skills 目录放置。

  2. 2

    拿到安装命令

    可以直接复制的命令是 npx skills add https://github.com/evanbacon/evanbacon.dev --skill expo-chat-ui;命令里的占位符换成自己的路径或密钥再执行。

  3. 3

    安装后重启宿主

    命令执行完要完全退出宿主工具再打开,让新装的 Skill 被重新扫描到,否则列表里可能看不到。

  4. 4

    按最小权限试用

    权限没有明确标注,第一次使用时只给一个无关紧要的任务,观察它到底读了哪些文件和网络。

常见问题

6 条

expo-chat-ui 怎么安装?

在终端执行 npx skills add https://github.com/evanbacon/evanbacon.dev --skill expo-chat-ui。装完完全退出宿主工具再打开,让它重新扫描本地 Skill。

expo-chat-ui 支持哪些 AI 工具?

公开资料标注支持 Codex、Claude、Cursor、Gemini CLI。其他工具只要能读取本地 Skill 目录,一般也能手动放进去,但要自己确认触发方式是否一致。

expo-chat-ui 需要什么权限?

权限级别没有明确标注。第一次使用时建议只给无关紧要的任务,观察它实际读写了哪些内容。

expo-chat-ui 是免费的吗?

公开资料没有标注明确许可证,收费和商用限制都无法确认。正式使用前请到来源仓库核对授权说明。

expo-chat-ui 用的人多吗?

公开数据是累计安装 242 次,近一周安装 10 次,下载 79 次,GitHub 209 星。这些数字来自第三方目录统计,只能当作热度参考,不代表质量或安全性。

expo-chat-ui 安全吗?

第三方扫描结果为通过,但这只覆盖已知的静态检查项,不能替代自己读一遍源码和安装命令。

值得选的地方和要先想清楚的地方

值得选的地方

  • 同时适配 4 种工具,换客户端不用重新找替代品。
  • 第三方扫描已通过,常见的高风险行为没有命中。

先想清楚的地方

  • 许可证没有明确标注,能不能商用、能不能再分发都不确定。
  • 第三方目录整理,版本更新和行为细节以来源仓库为准。

适不适合你

更适合

  • 已经在用 Codex,想直接扩展它能力的人。
  • 需要处理前端设计类任务、又不想每次都手写提示词的人。

不太适合

  • 需要明确授权才能用到公司项目里的场景。
  • 需要厂商级技术支持和 SLA 的生产系统。

前端设计类 Skill 对比

6 个同类
Skill总安装GitHub 星权限许可证扫描
frontend-design

前端设计 · Codex、Claude

8,823,360126,072只读MIT通过
vercel-composition-patterns

Vercel 组合模式 · Codex、Claude

3,858,81625,893需要联网MIT通过
vercel-react-native-skills

Vercel React native skills 前端 · Codex、Claude

2,468,66425,878只读MIT通过
adapt

adapt 搜索 · Codex、Claude

2,027,04023,125操作浏览器未明确通过
bolder

bolder 分析 · Codex、Claude

2,004,28823,130只读未明确通过
expo-chat-ui

世博会聊天界面 · 本页

242209权限未标注未明确通过

按安装量取同分类前几名;数字来自第三方目录统计,只作热度参考,不代表质量结论。

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

平台分布

Codex

35.59%
按下载量换算28

Claude

30.09%
按下载量换算24

Cursor

20.04%
按下载量换算16

Gemini CLI

9.69%
按下载量换算8

安全审计

Gen Agent Trust Hub

通过

Socket

通过

Snyk

通过

权限和风险

权限未标注

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

安装前确认

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

继续浏览同类 Skills