reusable-ui-components

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Creating Reusable UI Components for Expo Router

为Expo Router创建可复用UI组件

This guide covers building production-quality, portable UI components inspired by shadcn/ui, Base UI, Radix, and Konsta UI. Components follow iOS San Francisco design guidelines with liquid glass aesthetics and prioritize native primitives with graceful fallbacks.
本指南介绍如何构建受shadcn/ui、Base UI、Radix和Konsta UI启发的生产级、可移植UI组件。组件遵循iOS旧金山设计规范,采用液态玻璃美学,并优先使用原生基础组件,同时提供优雅的降级方案。

Philosophy

设计理念

Core Principles

核心原则

  1. Portable & Copy-Paste Ready - Components should be self-contained and easy to copy between projects
  2. Native-First - Always check for Expo Router primitives before building custom solutions
  3. iOS Design Language - Use San Francisco style guide as the baseline for all platforms
  4. Compound Components - Break complex components into composable sub-components
  5. CSS Variables for Customization - Use design tokens for theming, not hardcoded values
  6. Accessibility Built-In - Keyboard handling, safe areas, and screen reader support by default
  1. 可移植且支持直接复制粘贴 - 组件应具备自包含性,可轻松在项目间复制
  2. 原生优先 - 在构建自定义解决方案前,优先检查Expo Router是否提供原生基础组件
  3. iOS设计语言 - 以旧金山设计规范作为所有平台的基准
  4. 复合组件 - 将复杂组件拆分为可组合的子组件
  5. 使用CSS变量实现自定义 - 通过设计令牌实现主题定制,而非硬编码值
  6. 内置无障碍支持 - 默认包含键盘操作、安全区域适配和屏幕阅读器支持

Inspiration Sources

灵感来源

LibraryLearn From
shadcn/uiComponent structure, copy-paste architecture
Radix UICompound component patterns, accessibility primitives
Base UIHeadless component APIs, composition patterns
Konsta UIiOS liquid glass aesthetics, platform-adaptive styling
借鉴点
shadcn/ui组件结构、复制粘贴式架构
Radix UI复合组件模式、无障碍基础组件
Base UI无头组件API、组合模式
Konsta UIiOS液态玻璃美学、平台自适应样式

Component File Structure

组件文件结构

src/components/ui/
├── button.tsx          # Default (shared) implementation
├── button.ios.tsx      # iOS-specific overrides (optional)
├── button.web.tsx      # Web-specific overrides (optional)
└── button.android.tsx  # Android-specific overrides (optional)
Metro Resolution Priority:
  1. .ios.tsx
    /
    .android.tsx
    /
    .web.tsx
    (platform-specific)
  2. .native.tsx
    (iOS + Android)
  3. .tsx
    (fallback for all platforms)
src/components/ui/
├── button.tsx          # 默认(通用)实现
├── button.ios.tsx      # iOS专属重写(可选)
├── button.web.tsx      # Web专属重写(可选)
└── button.android.tsx  # Android专属重写(可选)
Metro解析优先级:
  1. .ios.tsx
    /
    .android.tsx
    /
    .web.tsx
    (平台专属)
  2. .native.tsx
    (iOS + Android)
  3. .tsx
    (所有平台的降级方案)

Design Tokens & CSS Variables

设计令牌与CSS变量

Global Theme Variables

全局主题变量

Define customizable design tokens in
src/global.css
:
css
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";

/* Import Apple system colors */
@import "./css/sf.css";

@layer theme {
  @theme {
    /* Typography Scale */
    --font-sans: system-ui;
    --font-mono: ui-monospace;
    --font-rounded: ui-rounded;

    /* Component Tokens */
    --component-radius: 12px;
    --component-radius-lg: 16px;
    --component-radius-full: 9999px;

    /* Spacing Scale */
    --spacing-xs: 4px;
    --spacing-sm: 8px;
    --spacing-md: 12px;
    --spacing-lg: 16px;
    --spacing-xl: 24px;

    /* Animation */
    --transition-fast: 150ms;
    --transition-normal: 200ms;
    --transition-slow: 300ms;
  }
}

/* Platform-specific overrides */
@media ios {
  :root {
    --font-sans: system-ui;
    --font-rounded: ui-rounded;
    --component-radius: 10px;
  }
}

@media android {
  :root {
    --font-sans: normal;
    --font-rounded: normal;
    --component-radius: 8px;
  }
}
src/global.css
中定义可自定义的设计令牌:
css
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";

/* 导入Apple系统颜色 */
@import "./css/sf.css";

@layer theme {
  @theme {
    /* 排版层级 */
    --font-sans: system-ui;
    --font-mono: ui-monospace;
    --font-rounded: ui-rounded;

    /* 组件令牌 */
    --component-radius: 12px;
    --component-radius-lg: 16px;
    --component-radius-full: 9999px;

    /* 间距层级 */
    --spacing-xs: 4px;
    --spacing-sm: 8px;
    --spacing-md: 12px;
    --spacing-lg: 16px;
    --spacing-xl: 24px;

    /* 动画 */
    --transition-fast: 150ms;
    --transition-normal: 200ms;
    --transition-slow: 300ms;
  }
}

/* 平台专属重写 */
@media ios {
  :root {
    --font-sans: system-ui;
    --font-rounded: ui-rounded;
    --component-radius: 10px;
  }
}

@media android {
  :root {
    --font-sans: normal;
    --font-rounded: normal;
    --component-radius: 8px;
  }
}

Apple System Colors

Apple系统颜色

Create platform-adaptive colors in
src/css/sf.css
:
css
@layer base {
  html {
    color-scheme: light dark;
  }
}

:root {
  /* Primary Colors */
  --sf-blue: light-dark(rgb(0 122 255), rgb(10 132 255));
  --sf-green: light-dark(rgb(52 199 89), rgb(48 209 89));
  --sf-red: light-dark(rgb(255 59 48), rgb(255 69 58));
  --sf-orange: light-dark(rgb(255 149 0), rgb(255 159 10));
  --sf-yellow: light-dark(rgb(255 204 0), rgb(255 214 10));
  --sf-purple: light-dark(rgb(175 82 222), rgb(191 90 242));
  --sf-pink: light-dark(rgb(255 45 85), rgb(255 55 95));

  /* Gray Scale */
  --sf-gray: light-dark(rgb(142 142 147), rgb(142 142 147));
  --sf-gray-2: light-dark(rgb(174 174 178), rgb(99 99 102));
  --sf-gray-3: light-dark(rgb(199 199 204), rgb(72 72 74));
  --sf-gray-4: light-dark(rgb(209 209 214), rgb(58 58 60));
  --sf-gray-5: light-dark(rgb(229 229 234), rgb(44 44 46));
  --sf-gray-6: light-dark(rgb(242 242 247), rgb(28 28 30));

  /* Text Colors */
  --sf-text: light-dark(rgb(0 0 0), rgb(255 255 255));
  --sf-text-2: light-dark(rgb(60 60 67 / 0.6), rgb(235 235 245 / 0.6));
  --sf-text-3: light-dark(rgb(60 60 67 / 0.3), rgb(235 235 245 / 0.3));
  --sf-text-placeholder: light-dark(rgb(60 60 67 / 0.3), rgb(235 235 245 / 0.3));

  /* Background Colors */
  --sf-bg: light-dark(rgb(255 255 255), rgb(0 0 0));
  --sf-bg-2: light-dark(rgb(242 242 247), rgb(28 28 30));
  --sf-grouped-bg: light-dark(rgb(242 242 247), rgb(0 0 0));
  --sf-grouped-bg-2: light-dark(rgb(255 255 255), rgb(28 28 30));

  /* Border & Fill */
  --sf-border: light-dark(rgb(60 60 67 / 0.12), rgb(84 84 88 / 0.65));
  --sf-fill: light-dark(rgb(120 120 128 / 0.2), rgb(120 120 128 / 0.32));

  /* Link Color */
  --sf-link: var(--sf-blue);
}

/* iOS: Use native platform colors */
@media ios {
  :root {
    --sf-blue: platformColor(systemBlue);
    --sf-green: platformColor(systemGreen);
    --sf-red: platformColor(systemRed);
    --sf-orange: platformColor(systemOrange);
    --sf-yellow: platformColor(systemYellow);
    --sf-purple: platformColor(systemPurple);
    --sf-pink: platformColor(systemPink);
    --sf-gray: platformColor(systemGray);
    --sf-gray-2: platformColor(systemGray2);
    --sf-gray-3: platformColor(systemGray3);
    --sf-gray-4: platformColor(systemGray4);
    --sf-gray-5: platformColor(systemGray5);
    --sf-gray-6: platformColor(systemGray6);
    --sf-text: platformColor(label);
    --sf-text-2: platformColor(secondaryLabel);
    --sf-text-3: platformColor(tertiaryLabel);
    --sf-text-placeholder: platformColor(placeholderText);
    --sf-bg: platformColor(systemBackground);
    --sf-bg-2: platformColor(secondarySystemBackground);
    --sf-grouped-bg: platformColor(systemGroupedBackground);
    --sf-grouped-bg-2: platformColor(secondarySystemGroupedBackground);
    --sf-border: platformColor(separator);
    --sf-fill: platformColor(tertiarySystemFill);
    --sf-link: platformColor(link);
  }
}

/* Register as Tailwind theme colors */
@layer theme {
  @theme {
    --color-sf-blue: var(--sf-blue);
    --color-sf-green: var(--sf-green);
    --color-sf-red: var(--sf-red);
    --color-sf-orange: var(--sf-orange);
    --color-sf-yellow: var(--sf-yellow);
    --color-sf-purple: var(--sf-purple);
    --color-sf-pink: var(--sf-pink);
    --color-sf-gray: var(--sf-gray);
    --color-sf-gray-2: var(--sf-gray-2);
    --color-sf-gray-3: var(--sf-gray-3);
    --color-sf-gray-4: var(--sf-gray-4);
    --color-sf-gray-5: var(--sf-gray-5);
    --color-sf-gray-6: var(--sf-gray-6);
    --color-sf-text: var(--sf-text);
    --color-sf-text-2: var(--sf-text-2);
    --color-sf-text-3: var(--sf-text-3);
    --color-sf-text-placeholder: var(--sf-text-placeholder);
    --color-sf-bg: var(--sf-bg);
    --color-sf-bg-2: var(--sf-bg-2);
    --color-sf-grouped-bg: var(--sf-grouped-bg);
    --color-sf-grouped-bg-2: var(--sf-grouped-bg-2);
    --color-sf-border: var(--sf-border);
    --color-sf-fill: var(--sf-fill);
    --color-sf-link: var(--sf-link);
  }
}
src/css/sf.css
中创建平台自适应颜色:
css
@layer base {
  html {
    color-scheme: light dark;
  }
}

:root {
  /* 主色调 */
  --sf-blue: light-dark(rgb(0 122 255), rgb(10 132 255));
  --sf-green: light-dark(rgb(52 199 89), rgb(48 209 89));
  --sf-red: light-dark(rgb(255 59 48), rgb(255 69 58));
  --sf-orange: light-dark(rgb(255 149 0), rgb(255 159 10));
  --sf-yellow: light-dark(rgb(255 204 0), rgb(255 214 10));
  --sf-purple: light-dark(rgb(175 82 222), rgb(191 90 242));
  --sf-pink: light-dark(rgb(255 45 85), rgb(255 55 95));

  /* 灰度色阶 */
  --sf-gray: light-dark(rgb(142 142 147), rgb(142 142 147));
  --sf-gray-2: light-dark(rgb(174 174 178), rgb(99 99 102));
  --sf-gray-3: light-dark(rgb(199 199 204), rgb(72 72 74));
  --sf-gray-4: light-dark(rgb(209 209 214), rgb(58 58 60));
  --sf-gray-5: light-dark(rgb(229 229 234), rgb(44 44 46));
  --sf-gray-6: light-dark(rgb(242 242 247), rgb(28 28 30));

  /* 文字颜色 */
  --sf-text: light-dark(rgb(0 0 0), rgb(255 255 255));
  --sf-text-2: light-dark(rgb(60 60 67 / 0.6), rgb(235 235 245 / 0.6));
  --sf-text-3: light-dark(rgb(60 60 67 / 0.3), rgb(235 235 245 / 0.3));
  --sf-text-placeholder: light-dark(rgb(60 60 67 / 0.3), rgb(235 235 245 / 0.3));

  /* 背景颜色 */
  --sf-bg: light-dark(rgb(255 255 255), rgb(0 0 0));
  --sf-bg-2: light-dark(rgb(242 242 247), rgb(28 28 30));
  --sf-grouped-bg: light-dark(rgb(242 242 247), rgb(0 0 0));
  --sf-grouped-bg-2: light-dark(rgb(255 255 255), rgb(28 28 30));

  /* 边框与填充色 */
  --sf-border: light-dark(rgb(60 60 67 / 0.12), rgb(84 84 88 / 0.65));
  --sf-fill: light-dark(rgb(120 120 128 / 0.2), rgb(120 120 128 / 0.32));

  /* 链接颜色 */
  --sf-link: var(--sf-blue);
}

/* iOS:使用原生平台颜色 */
@media ios {
  :root {
    --sf-blue: platformColor(systemBlue);
    --sf-green: platformColor(systemGreen);
    --sf-red: platformColor(systemRed);
    --sf-orange: platformColor(systemOrange);
    --sf-yellow: platformColor(systemYellow);
    --sf-purple: platformColor(systemPurple);
    --sf-pink: platformColor(systemPink);
    --sf-gray: platformColor(systemGray);
    --sf-gray-2: platformColor(systemGray2);
    --sf-gray-3: platformColor(systemGray3);
    --sf-gray-4: platformColor(systemGray4);
    --sf-gray-5: platformColor(systemGray5);
    --sf-gray-6: platformColor(systemGray6);
    --sf-text: platformColor(label);
    --sf-text-2: platformColor(secondaryLabel);
    --sf-text-3: platformColor(tertiaryLabel);
    --sf-text-placeholder: platformColor(placeholderText);
    --sf-bg: platformColor(systemBackground);
    --sf-bg-2: platformColor(secondarySystemBackground);
    --sf-grouped-bg: platformColor(systemGroupedBackground);
    --sf-grouped-bg-2: platformColor(secondarySystemGroupedBackground);
    --sf-border: platformColor(separator);
    --sf-fill: platformColor(tertiarySystemFill);
    --sf-link: platformColor(link);
  }
}

/* 注册为Tailwind主题颜色 */
@layer theme {
  @theme {
    --color-sf-blue: var(--sf-blue);
    --color-sf-green: var(--sf-green);
    --color-sf-red: var(--sf-red);
    --color-sf-orange: var(--sf-orange);
    --color-sf-yellow: var(--sf-yellow);
    --color-sf-purple: var(--sf-purple);
    --color-sf-pink: var(--sf-pink);
    --color-sf-gray: var(--sf-gray);
    --color-sf-gray-2: var(--sf-gray-2);
    --color-sf-gray-3: var(--sf-gray-3);
    --color-sf-gray-4: var(--sf-gray-4);
    --color-sf-gray-5: var(--sf-gray-5);
    --color-sf-gray-6: var(--sf-gray-6);
    --color-sf-text: var(--sf-text);
    --color-sf-text-2: var(--sf-text-2);
    --color-sf-text-3: var(--sf-text-3);
    --color-sf-text-placeholder: var(--sf-text-placeholder);
    --color-sf-bg: var(--sf-bg);
    --color-sf-bg-2: var(--sf-bg-2);
    --color-sf-grouped-bg: var(--sf-grouped-bg);
    --color-sf-grouped-bg-2: var(--sf-grouped-bg-2);
    --color-sf-border: var(--sf-border);
    --color-sf-fill: var(--sf-fill);
    --color-sf-link: var(--sf-link);
  }
}

Accessing CSS Variables in JavaScript

在JavaScript中访问CSS变量

tsx
import { useCSSVariable } from "@/tw";

function MyComponent() {
  const primaryColor = useCSSVariable("--sf-blue");
  const borderColor = useCSSVariable("--sf-border");

  return (
    <View style={{ borderColor }}>
      <Text style={{ color: primaryColor }}>Hello</Text>
    </View>
  );
}
tsx
import { useCSSVariable } from "@/tw";

function MyComponent() {
  const primaryColor = useCSSVariable("--sf-blue");
  const borderColor = useCSSVariable("--sf-border");

  return (
    <View style={{ borderColor }}>
      <Text style={{ color: primaryColor }}>Hello</Text>
    </View>
  );
}

Compound Component Pattern

复合组件模式

Use compound components for complex, multi-element UI. This provides flexibility while maintaining cohesive behavior.
对于复杂的多元素UI,使用复合组件模式。这种方式在保持行为一致性的同时提供了灵活性。

Template Structure

模板结构

tsx
"use client";

import React, { createContext, use } from "react";
import { View, Text, Pressable } from "@/tw";
import { cn } from "@/lib/utils";
import type { ViewProps, TextProps } from "react-native";

// 1. Define Context for shared state
interface ComponentContextValue {
  variant: "default" | "outline" | "ghost";
  size: "sm" | "md" | "lg";
  disabled?: boolean;
}

const ComponentContext = createContext<ComponentContextValue | null>(null);

function useComponentContext() {
  const context = use(ComponentContext);
  if (!context) {
    throw new Error("Component parts must be used within Component.Root");
  }
  return context;
}

// 2. Root component provides context
interface RootProps extends ViewProps {
  variant?: ComponentContextValue["variant"];
  size?: ComponentContextValue["size"];
  disabled?: boolean;
}

function Root({
  variant = "default",
  size = "md",
  disabled,
  children,
  className,
  ...props
}: RootProps) {
  return (
    <ComponentContext value={{ variant, size, disabled }}>
      <View
        {...props}
        className={cn(
          "flex-row items-center",
          disabled && "opacity-50",
          className
        )}
      >
        {children}
      </View>
    </ComponentContext>
  );
}

// 3. Sub-components consume context
function Label({ className, ...props }: TextProps) {
  const { size } = useComponentContext();

  return (
    <Text
      {...props}
      className={cn(
        "text-sf-text",
        size === "sm" && "text-sm",
        size === "md" && "text-base",
        size === "lg" && "text-lg",
        className
      )}
    />
  );
}

function Icon({ className, ...props }: ViewProps) {
  const { size } = useComponentContext();

  const sizeClass = {
    sm: "w-4 h-4",
    md: "w-5 h-5",
    lg: "w-6 h-6",
  }[size];

  return (
    <View {...props} className={cn(sizeClass, className)} />
  );
}

// 4. Export as compound component
export const Component = {
  Root,
  Label,
  Icon,
};

// 5. Convenience export for simple usage
export function SimpleComponent(props: RootProps & { label: string }) {
  const { label, ...rootProps } = props;
  return (
    <Component.Root {...rootProps}>
      <Component.Label>{label}</Component.Label>
    </Component.Root>
  );
}
tsx
"use client";

import React, { createContext, use } from "react";
import { View, Text, Pressable } from "@/tw";
import { cn } from "@/lib/utils";
import type { ViewProps, TextProps } from "react-native";

// 1. 定义共享状态的Context
interface ComponentContextValue {
  variant: "default" | "outline" | "ghost";
  size: "sm" | "md" | "lg";
  disabled?: boolean;
}

const ComponentContext = createContext<ComponentContextValue | null>(null);

function useComponentContext() {
  const context = use(ComponentContext);
  if (!context) {
    throw new Error("Component parts must be used within Component.Root");
  }
  return context;
}

// 2. Root组件提供Context
interface RootProps extends ViewProps {
  variant?: ComponentContextValue["variant"];
  size?: ComponentContextValue["size"];
  disabled?: boolean;
}

function Root({
  variant = "default",
  size = "md",
  disabled,
  children,
  className,
  ...props
}: RootProps) {
  return (
    <ComponentContext value={{ variant, size, disabled }}>
      <View
        {...props}
        className={cn(
          "flex-row items-center",
          disabled && "opacity-50",
          className
        )}
      >
        {children}
      </View>
    </ComponentContext>
  );
}

// 3. 子组件消费Context
function Label({ className, ...props }: TextProps) {
  const { size } = useComponentContext();

  return (
    <Text
      {...props}
      className={cn(
        "text-sf-text",
        size === "sm" && "text-sm",
        size === "md" && "text-base",
        size === "lg" && "text-lg",
        className
      )}
    />
  );
}

function Icon({ className, ...props }: ViewProps) {
  const { size } = useComponentContext();

  const sizeClass = {
    sm: "w-4 h-4",
    md: "w-5 h-5",
    lg: "w-6 h-6",
  }[size];

  return (
    <View {...props} className={cn(sizeClass, className)} />
  );
}

// 4. 作为复合组件导出
export const Component = {
  Root,
  Label,
  Icon,
};

// 5. 便捷导出,用于简单场景
export function SimpleComponent(props: RootProps & { label: string }) {
  const { label, ...rootProps } = props;
  return (
    <Component.Root {...rootProps}>
      <Component.Label>{label}</Component.Label>
    </Component.Root>
  );
}

Native-First Component Development

原生优先组件开发

Check for Expo Router Primitives First

优先检查Expo Router基础组件

Before building custom components, check if Expo Router or Expo provides a native primitive:
Component NeedCheck First
Navigation Stack
expo-router
Stack
Tab Navigation
expo-router
Tabs
Modals/Sheets
presentation: "modal"
or
presentation: "formSheet"
Links
expo-router
Link
Icons
expo-symbols
(SF Symbols)
Date Picker
@react-native-community/datetimepicker
Segmented Control
@react-native-segmented-control/segmented-control
Blur Effects
expo-blur
or
expo-glass-effect
Haptics
expo-haptics
Safe Areas
react-native-safe-area-context
在构建自定义组件前,先检查Expo Router或Expo是否提供了原生基础组件:
组件需求优先检查
导航栈
expo-router
Stack
标签页导航
expo-router
Tabs
模态框/底部弹窗
presentation: "modal"
presentation: "formSheet"
链接
expo-router
Link
图标
expo-symbols
(SF Symbols)
日期选择器
@react-native-community/datetimepicker
分段控制器
@react-native-segmented-control/segmented-control
模糊效果
expo-blur
expo-glass-effect
触觉反馈
expo-haptics
安全区域
react-native-safe-area-context

Platform Detection

平台检测

tsx
// Check current platform
if (process.env.EXPO_OS === "ios") {
  // iOS-specific behavior
} else if (process.env.EXPO_OS === "android") {
  // Android-specific behavior
} else if (process.env.EXPO_OS === "web") {
  // Web-specific behavior
}

// Check for specific features
import { isLiquidGlassAvailable } from "expo-glass-effect";
const GLASS = isLiquidGlassAvailable(); // iOS 26+ liquid glass
tsx
// 检查当前平台
if (process.env.EXPO_OS === "ios") {
  // iOS专属行为
} else if (process.env.EXPO_OS === "android") {
  // Android专属行为
} else if (process.env.EXPO_OS === "web") {
  // Web专属行为
}

// 检查特定功能
import { isLiquidGlassAvailable } from "expo-glass-effect";
const GLASS = isLiquidGlassAvailable(); // iOS 26+ 液态玻璃

Platform-Specific File Example: Switch

平台专属文件示例:Switch

switch.tsx (default - re-exports native):
tsx
export { Switch, type SwitchProps } from "react-native";
switch.web.tsx (web - iOS-styled custom):
tsx
"use client";

import { useState, useRef, useEffect } from "react";
import {
  View,
  Animated,
  PanResponder,
  StyleSheet,
  Pressable,
} from "react-native";

export type SwitchProps = {
  value?: boolean;
  onValueChange?: (value: boolean) => void;
  disabled?: boolean;
  thumbColor?: string;
  trackColor?: { true: string; false: string };
  ios_backgroundColor?: string;
};

export function Switch({
  value = false,
  onValueChange,
  disabled = false,
  thumbColor = "#fff",
  trackColor = { true: "#34C759", false: "#E9E9EA" },
  ios_backgroundColor,
}: SwitchProps) {
  const [isOn, setIsOn] = useState(value);
  const animatedValue = useRef(new Animated.Value(value ? 1 : 0)).current;

  useEffect(() => {
    setIsOn(value);
    Animated.spring(animatedValue, {
      toValue: value ? 1 : 0,
      useNativeDriver: false,
      friction: 8,
      tension: 40,
    }).start();
  }, [value, animatedValue]);

  const toggle = () => {
    if (disabled) return;
    const newValue = !isOn;
    setIsOn(newValue);
    onValueChange?.(newValue);
  };

  const translateX = animatedValue.interpolate({
    inputRange: [0, 1],
    outputRange: [2, 22],
  });

  const bgColor = animatedValue.interpolate({
    inputRange: [0, 1],
    outputRange: [
      ios_backgroundColor || trackColor.false,
      trackColor.true,
    ],
  });

  return (
    <Pressable onPress={toggle} disabled={disabled}>
      <Animated.View
        style={[
          styles.track,
          { backgroundColor: bgColor },
          disabled && styles.disabled,
        ]}
      >
        <Animated.View
          style={[
            styles.thumb,
            { backgroundColor: thumbColor, transform: [{ translateX }] },
          ]}
        />
      </Animated.View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  track: {
    width: 51,
    height: 31,
    borderRadius: 15.5,
    justifyContent: "center",
  },
  thumb: {
    width: 27,
    height: 27,
    borderRadius: 13.5,
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.2,
    shadowRadius: 2,
    elevation: 2,
  },
  disabled: {
    opacity: 0.5,
  },
});
switch.tsx(默认 - 重导出原生组件):
tsx
export { Switch, type SwitchProps } from "react-native";
switch.web.tsx(Web端 - iOS风格自定义组件):
tsx
"use client";

import { useState, useRef, useEffect } from "react";
import {
  View,
  Animated,
  PanResponder,
  StyleSheet,
  Pressable,
} from "react-native";

export type SwitchProps = {
  value?: boolean;
  onValueChange?: (value: boolean) => void;
  disabled?: boolean;
  thumbColor?: string;
  trackColor?: { true: string; false: string };
  ios_backgroundColor?: string;
};

export function Switch({
  value = false,
  onValueChange,
  disabled = false,
  thumbColor = "#fff",
  trackColor = { true: "#34C759", false: "#E9E9EA" },
  ios_backgroundColor,
}: SwitchProps) {
  const [isOn, setIsOn] = useState(value);
  const animatedValue = useRef(new Animated.Value(value ? 1 : 0)).current;

  useEffect(() => {
    setIsOn(value);
    Animated.spring(animatedValue, {
      toValue: value ? 1 : 0,
      useNativeDriver: false,
      friction: 8,
      tension: 40,
    }).start();
  }, [value, animatedValue]);

  const toggle = () => {
    if (disabled) return;
    const newValue = !isOn;
    setIsOn(newValue);
    onValueChange?.(newValue);
  };

  const translateX = animatedValue.interpolate({
    inputRange: [0, 1],
    outputRange: [2, 22],
  });

  const bgColor = animatedValue.interpolate({
    inputRange: [0, 1],
    outputRange: [
      ios_backgroundColor || trackColor.false,
      trackColor.true,
    ],
  });

  return (
    <Pressable onPress={toggle} disabled={disabled}>
      <Animated.View
        style={[
          styles.track,
          { backgroundColor: bgColor },
          disabled && styles.disabled,
        ]}
      >
        <Animated.View
          style={[
            styles.thumb,
            { backgroundColor: thumbColor, transform: [{ translateX }] },
          ]}
        />
      </Animated.View>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  track: {
    width: 51,
    height: 31,
    borderRadius: 15.5,
    justifyContent: "center",
  },
  thumb: {
    width: 27,
    height: 27,
    borderRadius: 13.5,
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.2,
    shadowRadius: 2,
    elevation: 2,
  },
  disabled: {
    opacity: 0.5,
  },
});

Accessibility Patterns

无障碍访问模式

Keyboard Avoidance

键盘适配

For forms with text input, proper keyboard handling is critical:
tsx
import {
  useReanimatedKeyboardAnimation,
  useKeyboardHandler,
} from "react-native-keyboard-controller";
import { useAnimatedStyle } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";

function KeyboardAwareForm({ children }: { children: React.ReactNode }) {
  const { bottom } = useSafeAreaInsets();
  const { height, progress } = useReanimatedKeyboardAnimation();

  const animatedStyle = useAnimatedStyle(() => ({
    paddingBottom: Math.max(bottom, Math.abs(height.value)),
  }));

  return (
    <Animated.View style={[{ flex: 1 }, animatedStyle]}>
      {children}
    </Animated.View>
  );
}
对于包含文本输入的表单,正确的键盘处理至关重要:
tsx
import {
  useReanimatedKeyboardAnimation,
  useKeyboardHandler,
} from "react-native-keyboard-controller";
import { useAnimatedStyle } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";

function KeyboardAwareForm({ children }: { children: React.ReactNode }) {
  const { bottom } = useSafeAreaInsets();
  const { height, progress } = useReanimatedKeyboardAnimation();

  const animatedStyle = useAnimatedStyle(() => ({
    paddingBottom: Math.max(bottom, Math.abs(height.value)),
  }));

  return (
    <Animated.View style={[{ flex: 1 }, animatedStyle]}>
      {children}
    </Animated.View>
  );
}

Safe Area Handling

安全区域处理

Always account for safe areas on notched devices:
tsx
import { useSafeAreaInsets } from "react-native-safe-area-context";

function SafeContainer({ children }: { children: React.ReactNode }) {
  const { top, bottom, left, right } = useSafeAreaInsets();

  return (
    <View
      style={{
        flex: 1,
        paddingTop: top,
        paddingBottom: bottom,
        paddingLeft: left,
        paddingRight: right,
      }}
    >
      {children}
    </View>
  );
}
始终考虑带刘海设备的安全区域:
tsx
import { useSafeAreaInsets } from "react-native-safe-area-context";

function SafeContainer({ children }: { children: React.ReactNode }) {
  const { top, bottom, left, right } = useSafeAreaInsets();

  return (
    <View
      style={{
        flex: 1,
        paddingTop: top,
        paddingBottom: bottom,
        paddingLeft: left,
        paddingRight: right,
      }}
    >
      {children}
    </View>
  );
}

Form Accessibility Pattern

表单无障碍访问模式

tsx
import { View, Text, TextInput } from "@/tw";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";

interface FormFieldProps {
  label: string;
  hint?: string;
  error?: string;
  children: React.ReactNode;
}

function FormField({ label, hint, error, children }: FormFieldProps) {
  return (
    <View className="gap-1">
      <Text
        className="text-sf-text-2 text-sm font-medium"
        accessibilityRole="text"
      >
        {label}
      </Text>
      {children}
      {hint && !error && (
        <Text className="text-sf-text-3 text-xs">{hint}</Text>
      )}
      {error && (
        <Text
          className="text-sf-red text-xs"
          accessibilityRole="alert"
        >
          {error}
        </Text>
      )}
    </View>
  );
}

function AccessibleForm() {
  const { bottom } = useSafeAreaInsets();

  return (
    <KeyboardAwareScrollView
      contentContainerStyle={{
        padding: 16,
        paddingBottom: bottom + 16,
        gap: 16,
      }}
      keyboardShouldPersistTaps="handled"
    >
      <FormField label="Email" hint="We'll never share your email">
        <TextInput
          className="bg-sf-fill rounded-xl px-4 py-3 text-sf-text"
          placeholder="you@example.com"
          keyboardType="email-address"
          autoCapitalize="none"
          autoComplete="email"
          textContentType="emailAddress"
          accessibilityLabel="Email address"
        />
      </FormField>

      <FormField label="Password">
        <TextInput
          className="bg-sf-fill rounded-xl px-4 py-3 text-sf-text"
          placeholder="••••••••"
          secureTextEntry
          autoComplete="password"
          textContentType="password"
          accessibilityLabel="Password"
        />
      </FormField>
    </KeyboardAwareScrollView>
  );
}
tsx
import { View, Text, TextInput } from "@/tw";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";

interface FormFieldProps {
  label: string;
  hint?: string;
  error?: string;
  children: React.ReactNode;
}

function FormField({ label, hint, error, children }: FormFieldProps) {
  return (
    <View className="gap-1">
      <Text
        className="text-sf-text-2 text-sm font-medium"
        accessibilityRole="text"
      >
        {label}
      </Text>
      {children}
      {hint && !error && (
        <Text className="text-sf-text-3 text-xs">{hint}</Text>
      )}
      {error && (
        <Text
          className="text-sf-red text-xs"
          accessibilityRole="alert"
        >
          {error}
        </Text>
      )}
    </View>
  );
}

function AccessibleForm() {
  const { bottom } = useSafeAreaInsets();

  return (
    <KeyboardAwareScrollView
      contentContainerStyle={{
        padding: 16,
        paddingBottom: bottom + 16,
        gap: 16,
      }}
      keyboardShouldPersistTaps="handled"
    >
      <FormField label="Email" hint="我们绝不会分享您的邮箱">
        <TextInput
          className="bg-sf-fill rounded-xl px-4 py-3 text-sf-text"
          placeholder="you@example.com"
          keyboardType="email-address"
          autoCapitalize="none"
          autoComplete="email"
          textContentType="emailAddress"
          accessibilityLabel="邮箱地址"
        />
      </FormField>

      <FormField label="密码">
        <TextInput
          className="bg-sf-fill rounded-xl px-4 py-3 text-sf-text"
          placeholder="••••••••"
          secureTextEntry
          autoComplete="password"
          textContentType="password"
          accessibilityLabel="密码"
        />
      </FormField>
    </KeyboardAwareScrollView>
  );
}

iOS Liquid Glass Styling

iOS液态玻璃样式

Detecting Liquid Glass Support

检测液态玻璃支持

tsx
import { isLiquidGlassAvailable } from "expo-glass-effect";

const GLASS = isLiquidGlassAvailable();

const HEADER_OPTIONS = GLASS
  ? {
      headerTransparent: true,
      headerShadowVisible: false,
      headerBlurEffect: "none",
    }
  : {
      headerTransparent: true,
      headerBlurEffect: "systemChromeMaterial",
      headerShadowVisible: true,
    };
tsx
import { isLiquidGlassAvailable } from "expo-glass-effect";

const GLASS = isLiquidGlassAvailable();

const HEADER_OPTIONS = GLASS
  ? {
      headerTransparent: true,
      headerShadowVisible: false,
      headerBlurEffect: "none",
    }
  : {
      headerTransparent: true,
      headerBlurEffect: "systemChromeMaterial",
      headerShadowVisible: true,
    };

Tab Bar with Glass Effect

带玻璃效果的标签栏

tsx
import { BlurView } from "expo-blur";

function GlassTabBarBackground() {
  return (
    <BlurView
      intensity={100}
      tint="systemChromeMaterial"
      style={StyleSheet.absoluteFill}
    />
  );
}

// Usage in Tabs
const TAB_OPTIONS =
  process.env.EXPO_OS === "ios"
    ? {
        tabBarBackground: GlassTabBarBackground,
        tabBarStyle: { position: "absolute" },
      }
    : {};
tsx
import { BlurView } from "expo-blur";

function GlassTabBarBackground() {
  return (
    <BlurView
      intensity={100}
      tint="systemChromeMaterial"
      style={StyleSheet.absoluteFill}
    />
  );
}

// 在Tabs中使用
const TAB_OPTIONS =
  process.env.EXPO_OS === "ios"
    ? {
        tabBarBackground: GlassTabBarBackground,
        tabBarStyle: { position: "absolute" },
      }
    : {};

Glass Card Component

玻璃卡片组件

tsx
import { BlurView } from "expo-blur";
import { View } from "@/tw";
import { cn } from "@/lib/utils";

interface GlassCardProps extends React.ComponentProps<typeof View> {
  intensity?: number;
}

function GlassCard({
  intensity = 50,
  className,
  children,
  ...props
}: GlassCardProps) {
  if (process.env.EXPO_OS !== "ios") {
    // Fallback for non-iOS
    return (
      <View
        {...props}
        className={cn(
          "bg-sf-bg-2/80 rounded-2xl overflow-hidden",
          className
        )}
      >
        {children}
      </View>
    );
  }

  return (
    <View
      {...props}
      className={cn("rounded-2xl overflow-hidden", className)}
    >
      <BlurView
        intensity={intensity}
        tint="systemChromeMaterial"
        style={StyleSheet.absoluteFill}
      />
      <View className="relative">{children}</View>
    </View>
  );
}
tsx
import { BlurView } from "expo-blur";
import { View } from "@/tw";
import { cn } from "@/lib/utils";

interface GlassCardProps extends React.ComponentProps<typeof View> {
  intensity?: number;
}

function GlassCard({
  intensity = 50,
  className,
  children,
  ...props
}: GlassCardProps) {
  if (process.env.EXPO_OS !== "ios") {
    // 非iOS平台的降级方案
    return (
      <View
        {...props}
        className={cn(
          "bg-sf-bg-2/80 rounded-2xl overflow-hidden",
          className
        )}
      >
        {children}
      </View>
    );
  }

  return (
    <View
      {...props}
      className={cn("rounded-2xl overflow-hidden", className)}
    >
      <BlurView
        intensity={intensity}
        tint="systemChromeMaterial"
        style={StyleSheet.absoluteFill}
      />
      <View className="relative">{children}</View>
    </View>
  );
}

Form Components Pattern

表单组件模式

The
Form
compound component demonstrates all principles together:
tsx
"use client";

import React, { createContext, use } from "react";
import { View, Text, TextInput, ScrollView, TouchableHighlight } from "@/tw";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { cn } from "@/lib/utils";
import { useCSSVariable } from "@/tw";

// Context for form styling
const FormContext = createContext<{
  listStyle: "grouped" | "inset";
  sheet?: boolean;
}>({ listStyle: "inset" });

// List wrapper with pull-to-refresh
function List({
  children,
  listStyle = "inset",
  sheet,
  ...props
}: React.ComponentProps<typeof ScrollView> & {
  listStyle?: "grouped" | "inset";
  sheet?: boolean;
}) {
  const { bottom } = useSafeAreaInsets();

  return (
    <FormContext value={{ listStyle, sheet }}>
      <ScrollView
        contentContainerStyle={{ paddingVertical: 16, gap: 24 }}
        contentInsetAdjustmentBehavior="automatic"
        scrollIndicatorInsets={{ bottom }}
        className={cn(
          sheet ? "bg-transparent" : "bg-sf-grouped-bg",
          props.className
        )}
        {...props}
      >
        {children}
      </ScrollView>
    </FormContext>
  );
}

// Section groups related items
function Section({
  children,
  title,
  footer,
  ...props
}: React.ComponentProps<typeof View> & {
  title?: string;
  footer?: string;
}) {
  const { listStyle, sheet } = use(FormContext);
  const isInset = listStyle === "inset";

  return (
    <View style={{ paddingHorizontal: isInset ? 16 : 0 }} {...props}>
      {title && (
        <Text className="uppercase text-sf-text-2 text-sm px-5 pb-2">
          {title}
        </Text>
      )}
      <View
        className={cn(
          sheet ? "bg-sf-bg-2" : "bg-sf-grouped-bg-2",
          isInset ? "rounded-xl overflow-hidden" : "border-y border-sf-border"
        )}
      >
        {React.Children.map(children, (child, index) => (
          <>
            {child}
            {index < React.Children.count(children) - 1 && (
              <View className="border-b border-sf-border ml-4" />
            )}
          </>
        ))}
      </View>
      {footer && (
        <Text className="text-sf-text-2 text-sm px-5 pt-2">{footer}</Text>
      )}
    </View>
  );
}

// Individual form item with optional navigation
function Item({
  children,
  onPress,
  href,
  ...props
}: React.ComponentProps<typeof View> & {
  onPress?: () => void;
  href?: string;
}) {
  const underlayColor = useCSSVariable("--sf-gray-4");

  const content = (
    <View className="flex-row items-center px-4 py-3 min-h-[44px]" {...props}>
      {children}
    </View>
  );

  if (!onPress && !href) return content;

  return (
    <TouchableHighlight
      onPress={onPress}
      underlayColor={underlayColor}
      className="web:hover:bg-sf-fill web:transition-colors"
    >
      {content}
    </TouchableHighlight>
  );
}

// Text label
function Label({ className, ...props }: React.ComponentProps<typeof Text>) {
  return (
    <Text
      {...props}
      className={cn("text-sf-text text-base flex-1", className)}
    />
  );
}

// Hint/value on the right
function Hint({ className, ...props }: React.ComponentProps<typeof Text>) {
  return (
    <Text
      {...props}
      className={cn("text-sf-text-2 text-base", className)}
    />
  );
}

// Export compound component
export const Form = {
  List,
  Section,
  Item,
  Label,
  Hint,
};
Form
复合组件综合了所有设计原则:
tsx
"use client";

import React, { createContext, use } from "react";
import { View, Text, TextInput, ScrollView, TouchableHighlight } from "@/tw";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { cn } from "@/lib/utils";
import { useCSSVariable } from "@/tw";

// 表单样式的Context
const FormContext = createContext<{
  listStyle: "grouped" | "inset";
  sheet?: boolean;
}>({ listStyle: "inset" });

// 带下拉刷新的列表容器
function List({
  children,
  listStyle = "inset",
  sheet,
  ...props
}: React.ComponentProps<typeof ScrollView> & {
  listStyle?: "grouped" | "inset";
  sheet?: boolean;
}) {
  const { bottom } = useSafeAreaInsets();

  return (
    <FormContext value={{ listStyle, sheet }}>
      <ScrollView
        contentContainerStyle={{ paddingVertical: 16, gap: 24 }}
        contentInsetAdjustmentBehavior="automatic"
        scrollIndicatorInsets={{ bottom }}
        className={cn(
          sheet ? "bg-transparent" : "bg-sf-grouped-bg",
          props.className
        )}
        {...props}
      >
        {children}
      </ScrollView>
    </FormContext>
  );
}

// 用于分组相关项的区块
function Section({
  children,
  title,
  footer,
  ...props
}: React.ComponentProps<typeof View> & {
  title?: string;
  footer?: string;
}) {
  const { listStyle, sheet } = use(FormContext);
  const isInset = listStyle === "inset";

  return (
    <View style={{ paddingHorizontal: isInset ? 16 : 0 }} {...props}>
      {title && (
        <Text className="uppercase text-sf-text-2 text-sm px-5 pb-2">
          {title}
        </Text>
      )}
      <View
        className={cn(
          sheet ? "bg-sf-bg-2" : "bg-sf-grouped-bg-2",
          isInset ? "rounded-xl overflow-hidden" : "border-y border-sf-border"
        )}
      >
        {React.Children.map(children, (child, index) => (
          <>
            {child}
            {index < React.Children.count(children) - 1 && (
              <View className="border-b border-sf-border ml-4" />
            )}
          </>
        ))}
      </View>
      {footer && (
        <Text className="text-sf-text-2 text-sm px-5 pt-2">{footer}</Text>
      )}
    </View>
  );
}

// 单个表单项,支持可选导航
function Item({
  children,
  onPress,
  href,
  ...props
}: React.ComponentProps<typeof View> & {
  onPress?: () => void;
  href?: string;
}) {
  const underlayColor = useCSSVariable("--sf-gray-4");

  const content = (
    <View className="flex-row items-center px-4 py-3 min-h-[44px]" {...props}>
      {children}
    </View>
  );

  if (!onPress && !href) return content;

  return (
    <TouchableHighlight
      onPress={onPress}
      underlayColor={underlayColor}
      className="web:hover:bg-sf-fill web:transition-colors"
    >
      {content}
    </TouchableHighlight>
  );
}

// 文字标签
function Label({ className, ...props }: React.ComponentProps<typeof Text>) {
  return (
    <Text
      {...props}
      className={cn("text-sf-text text-base flex-1", className)}
    />
  );
}

// 右侧的提示/值
function Hint({ className, ...props }: React.ComponentProps<typeof Text>) {
  return (
    <Text
      {...props}
      className={cn("text-sf-text-2 text-base", className)}
    />
  );
}

// 导出复合组件
export const Form = {
  List,
  Section,
  Item,
  Label,
  Hint,
};

Usage

使用示例

tsx
<Form.List>
  <Form.Section title="Account" footer="Your account settings">
    <Form.Item href="/profile">
      <Form.Label>Profile</Form.Label>
      <Form.Hint>John Doe</Form.Hint>
      <ChevronRight />
    </Form.Item>
    <Form.Item href="/email">
      <Form.Label>Email</Form.Label>
      <Form.Hint>john@example.com</Form.Hint>
      <ChevronRight />
    </Form.Item>
  </Form.Section>

  <Form.Section title="Preferences">
    <Form.Item>
      <Form.Label>Dark Mode</Form.Label>
      <Switch value={darkMode} onValueChange={setDarkMode} />
    </Form.Item>
  </Form.Section>
</Form.List>
tsx
<Form.List>
  <Form.Section title="账户" footer="您的账户设置">
    <Form.Item href="/profile">
      <Form.Label>个人资料</Form.Label>
      <Form.Hint>John Doe</Form.Hint>
      <ChevronRight />
    </Form.Item>
    <Form.Item href="/email">
      <Form.Label>邮箱</Form.Label>
      <Form.Hint>john@example.com</Form.Hint>
      <ChevronRight />
    </Form.Item>
  </Form.Section>

  <Form.Section title="偏好设置">
    <Form.Item>
      <Form.Label>深色模式</Form.Label>
      <Switch value={darkMode} onValueChange={setDarkMode} />
    </Form.Item>
  </Form.Section>
</Form.List>

Haptic Feedback

触觉反馈

Platform-Safe Haptics

跨平台安全触觉反馈

lib/haptics.ts (native):
tsx
import * as Haptics from "expo-haptics";

export const haptics = {
  light: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
  medium: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium),
  heavy: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy),
  success: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success),
  warning: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning),
  error: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error),
  selection: () => Haptics.selectionAsync(),
};
lib/haptics.web.ts (web - no-op):
tsx
export const haptics = {
  light: () => {},
  medium: () => {},
  heavy: () => {},
  success: () => {},
  warning: () => {},
  error: () => {},
  selection: () => {},
};
lib/haptics.ts(原生平台):
tsx
import * as Haptics from "expo-haptics";

export const haptics = {
  light: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
  medium: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium),
  heavy: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy),
  success: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success),
  warning: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning),
  error: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error),
  selection: () => Haptics.selectionAsync(),
};
lib/haptics.web.ts(Web平台 - 空实现):
tsx
export const haptics = {
  light: () => {},
  medium: () => {},
  heavy: () => {},
  success: () => {},
  warning: () => {},
  error: () => {},
  selection: () => {},
};

Usage in Components

在组件中使用

tsx
import { haptics } from "@/lib/haptics";

function HapticButton({ onPress, children }) {
  const handlePress = () => {
    haptics.light();
    onPress?.();
  };

  return <Pressable onPress={handlePress}>{children}</Pressable>;
}
tsx
import { haptics } from "@/lib/haptics";

function HapticButton({ onPress, children }) {
  const handlePress = () => {
    haptics.light();
    onPress?.();
  };

  return <Pressable onPress={handlePress}>{children}</Pressable>;
}

Icon System

图标系统

SF Symbol Icons with Fallbacks

带降级方案的SF Symbol图标

tsx
import { SymbolView, SymbolWeight } from "expo-symbols";
import { MaterialIcons } from "@expo/vector-icons";

// Map SF Symbol names to Material Icons
const ICON_MAPPING: Record<string, string> = {
  "house.fill": "home",
  "gear": "settings",
  "person.fill": "person",
  "magnifyingglass": "search",
  "chevron.right": "chevron_right",
};

interface IconProps {
  name: string;
  size?: number;
  color?: string;
  weight?: SymbolWeight;
}

export function Icon({ name, size = 24, color, weight }: IconProps) {
  if (process.env.EXPO_OS === "ios") {
    return (
      <SymbolView
        name={name}
        size={size}
        tintColor={color}
        weight={weight}
      />
    );
  }

  const materialName = ICON_MAPPING[name] || name;
  return <MaterialIcons name={materialName} size={size} color={color} />;
}
tsx
import { SymbolView, SymbolWeight } from "expo-symbols";
import { MaterialIcons } from "@expo/vector-icons";

// 将SF Symbol名称映射到Material Icons
const ICON_MAPPING: Record<string, string> = {
  "house.fill": "home",
  "gear": "settings",
  "person.fill": "person",
  "magnifyingglass": "search",
  "chevron.right": "chevron_right",
};

interface IconProps {
  name: string;
  size?: number;
  color?: string;
  weight?: SymbolWeight;
}

export function Icon({ name, size = 24, color, weight }: IconProps) {
  if (process.env.EXPO_OS === "ios") {
    return (
      <SymbolView
        name={name}
        size={size}
        tintColor={color}
        weight={weight}
      />
    );
  }

  const materialName = ICON_MAPPING[name] || name;
  return <MaterialIcons name={materialName} size={size} color={color} />;
}

Component Checklist

组件检查清单

When creating a new component, ensure:
  • Portable: Self-contained, minimal external dependencies
  • Typed: Full TypeScript types for props
  • Themed: Uses CSS variables for colors, not hardcoded values
  • Accessible: Proper accessibility roles and labels
  • Keyboard-aware: Handles keyboard avoidance for inputs
  • Safe area-aware: Respects device safe areas
  • Platform-adaptive: Uses native primitives where available
  • Compound structure: Complex components use compound pattern
  • Haptic feedback: Provides tactile feedback on iOS
  • Dark mode: Supports light and dark color schemes
  • Display name: Set
    displayName
    in dev for debugging
tsx
if (__DEV__) {
  MyComponent.displayName = "MyComponent";
}
创建新组件时,确保满足以下条件:
  • 可移植: 自包含,外部依赖极少
  • 类型化: 为Props提供完整的TypeScript类型
  • 主题化: 使用CSS变量定义颜色,而非硬编码值
  • 无障碍: 具备正确的无障碍角色和标签
  • 键盘适配: 处理输入框的键盘避让
  • 安全区域适配: 尊重设备的安全区域
  • 平台自适应: 尽可能使用原生基础组件
  • 复合结构: 复杂组件采用复合模式
  • 触觉反馈: 在iOS平台提供触觉反馈
  • 深色模式: 支持亮色和暗色配色方案
  • 显示名称: 在开发环境中设置
    displayName
    以方便调试
tsx
if (__DEV__) {
  MyComponent.displayName = "MyComponent";
}

Dependencies Reference

依赖参考

PackagePurpose
react-native-css
CSS runtime for React Native
nativewind
Metro transformer for Tailwind
tailwindcss
Utility-first CSS
@tailwindcss/postcss
PostCSS plugin for Tailwind v4
tailwind-merge
Merge Tailwind classes safely
clsx
Conditional class names
react-native-safe-area-context
Safe area handling
react-native-keyboard-controller
Keyboard animations
react-native-reanimated
Gesture animations
expo-haptics
Haptic feedback
expo-symbols
SF Symbols
expo-blur
Blur effects
expo-glass-effect
iOS 26 liquid glass
@bacons/apple-colors
Native iOS colors
用途
react-native-css
React Native的CSS运行时
nativewind
Tailwind的Metro转换器
tailwindcss
实用优先的CSS框架
@tailwindcss/postcss
Tailwind v4的PostCSS插件
tailwind-merge
安全合并Tailwind类名
clsx
条件类名处理
react-native-safe-area-context
安全区域处理
react-native-keyboard-controller
键盘动画
react-native-reanimated
手势动画
expo-haptics
触觉反馈
expo-symbols
SF Symbols图标
expo-blur
模糊效果
expo-glass-effect
iOS 26液态玻璃效果
@bacons/apple-colors
原生iOS颜色