Expo UI 现已稳定:一次导入即可使用 SwiftUI 和 Jetpack Compose

从 SDK 56 开始,@expo/ui 进入稳定版。一次 import,就能在 iOS 上用上 SwiftUI,在 Android 上用上 Jetpack Compose,不需要用 JS 重新实现一遍。

中文
复制
Expo UI is now stable: SwiftUI and Jetpack Compose from a single import

Expo UI 现已稳定,可用于生产环境。一次 JS import,就能免费获得由 SwiftUI / Compose 支撑的原生基础视图。它还提供了可直接替换的视图,你顺手就能把现有的社区包从 package.json 里删掉。

Expo UI 中的通用组件,覆盖 Android、iOS 和 Web。
Expo UI 中的通用组件,覆盖 Android、iOS 和 Web。

Expo UI 的通用组件,覆盖 Android、iOS 和 Web。

从 SDK 56 开始,@expo/ui 让你在 iOS 上用 SwiftUI、在 Android 上用 Jetpack Compose。是真正的原生基础组件,不是 JavaScript 重新实现的一套。

Expo UI 已包含在默认的 create-expo-app 模板中,并打包进了 Expo Go,所以创建或打开项目的那一刻就能上手试。

这个里程碑是三个 SDK 周期迭代的结果:

  • SDK 53 —— 首个原型。为 SwiftUI 和 Jetpack Compose 做了少量叶子组件。

  • SDK 54 —— <Host>、SwiftUI modifiers,以及 FormList 这类容器视图。hot-chocolate 示例证明了用这种方式可以做出一个完整的应用。

  • SDK 55 —— Jetpack Compose 支持进入 beta,同时梳理了 SwiftUI 这一层的接口,让每个组件、prop 和 modifier 的名称都与 Apple 文档一一对应。

  • SDK 56 —— Jetpack Compose 这一层也做了同样的梳理,在两者之上加了一层通用层,另外还有几个长期存在的社区包现在有了原生的一比一替代品。

下面说说每一部分在实际使用中是什么样。

一次 import,全平台通用:通用组件

SDK 56 里你能看到的最大变化是通用层。你不再需要从 @expo/ui/swift-ui@expo/ui/jetpack-compose 导入,而是从 @expo/ui 导入,底层会自动使用符合平台习惯的实现:

import { Host, FieldGroup, Row, Switch, Slider, Text, Spacer } from '@expo/ui';

在底层,每个通用组件只是一个包装:在 iOS 上渲染 SwiftUI 版本,在 Android 上渲染 Compose 版本。中间没有 JS 重新实现,所以无论哪种情况,你用的都是真正的平台组件。布局基础组件、文本、输入、控件和 sheet 都在覆盖范围内:HostRowColumnScrollView 等等。进一步了解通用组件

几个值得点名的命名选择:是 Switch,不是 Toggle;是 Column / Row,不是 HStack / VStack。通用层更倾向于 React Native 开发者每天都在敲的那些名字。想要 SwiftUI 风格的名字时,它依然就在 @expo/ui/swift-ui 下面。

通用组件展示:设置页

FieldGroup 是 SwiftUI Form 的通用对应物——一个分组、分节的列表,带节标题、页脚,在 iOS 和 Android 上都有符合平台习惯的样式。注意下面代码片段里的复合组件:FieldGroup.Section 表示分节,FieldGroup.SectionFooter 表示下方灰色的说明文字。

import { useState } from 'react';
import { Button, FieldGroup, Host, Row, Slider, Spacer, Switch, Text } from '@expo/ui';

export default function SettingsScreen() {
  const [notifications, setNotifications] = useState(true);
  const [sounds, setSounds] = useState(false);
  const [brightness, setBrightness] = useState(0.6);

  return (
    <Host style={{ flex: 1 }}>
      <FieldGroup>
        <FieldGroup.Section title="Notifications">
          <LabeledRow label="Push notifications">
            <Switch value={notifications} onValueChange={setNotifications} />
          </LabeledRow>
          <LabeledRow label="Sounds">
            <Switch value={sounds} onValueChange={setSounds} />
          </LabeledRow>
          <FieldGroup.SectionFooter>
            <Text textStyle={{ fontSize: 13, color: '#6c6c70' }}>
              Notification previews can expose sensitive content on the lock screen.
            </Text>
          </FieldGroup.SectionFooter>
        </FieldGroup.Section>

        <FieldGroup.Section title="Display">
          <LabeledRow label="Brightness">
            <Slider value={brightness} onValueChange={setBrightness} />
          </LabeledRow>
        </FieldGroup.Section>

        <FieldGroup.Section>
          <Row alignment="center" style={{ padding: 12 }}>
            <Spacer flexible />
            <Button variant="outlined" onPress={() => alert('Signed out')} label="Sign out" />
            <Spacer flexible />
          </Row>
        </FieldGroup.Section>
      </FieldGroup>
    </Host>
  );
}

function LabeledRow({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <Row alignment="center" spacing={16}>
      <Text>{label}</Text>
      <Spacer flexible />
      {children}
    </Row>
  );
}

同一份代码,两个平台。在 iOS 上,你得到的是 SwiftUI 的 Form,带 inset-grouped 分节,以及系统的 SwitchSlider;在 Android 上,你得到的是 Material 3 的分组列表。把它放进一个页面,它看起来就像系统自带的一部分(不需要拆成 .ios.tsx / .android.tsx)。

平台包(@expo/ui/swift-ui@expo/ui/jetpack-compose)仍然和通用层并存,供某些页面需要 SwiftUI 的 glassEffect() 或 Compose 的 DockedSearchBar 时使用。可以在同一个 Host 里混着用。

关于通用层的两点早期说明:

  • 这是第一版。 有些通用组件在三个平台上都表现良好,有些则不行,因为 SwiftUI、Compose 和 Web 并不总是提供同样的原语。这个 SDK 周期里请告诉我们:哪些通用组件对你有用,哪些没用,缺了哪些,以及你在哪些地方最终直接用了 @expo/ui/swift-ui@expo/ui/jetpack-compose

  • Web 是实验性的。 通用组件有 Web 实现,但质量还没达到原生同样的水准。随着我们根据你们的反馈打磨它们,后续 SDK 里还会有更多迭代。

稳定的原生 API 与新功能

两轮审计做完之后,你找到的大多数 SwiftUI 或 Compose 示例(Apple 文档、Google 文档、某篇 Medium 文章、LLM 的训练数据)距离在 Expo UI 里跑起来,基本只差一次查找替换。组件名、prop 名、modifier 名,全都一样。

具体来说:SwiftUI 的 ToggleForm@expo/ui/swift-ui 里保留原名;Compose 的 LazyColumn@expo/ui/jetpack-compose 里保留原名。prop 和 modifier 的命名遵循同样的规则。

SDK 56 包含了 Compose 审查的最后一批重命名。从这个版本开始,API 基本稳定。

除了审查之外,本周期还发布了一些新功能:

  • 可扩展性。 SwiftUI 和 Jetpack Compose 提供了数百个视图和修饰符——远超我们能在 @expo/ui 中封装的规模。为了弥补这一差距,你现在可以自行构建 SwiftUI 和 Jetpack Compose 视图与修饰符,由 Expo UI 负责处理布局、prop 和事件管道。进一步了解 SwiftUIJetpack Compose

  • Material 3 动态颜色。 useMaterialColors 提供跟随系统主题的 Material 3 动态颜色。

  • Material Symbols 目录。 Icon 组件与 @expo/material-symbols 配合,让完整的 Material Symbols 目录触手可及。

  • Worklet 驱动的原生状态。 用于 SwiftUIJetpack ComposeuseNativeState 让你构建无闪烁、由 UI 线程驱动的控件。我们会专门写一篇后续文章来介绍。

社区包的直接替代品

大多数 React Native 应用最终都会引入同一小堆社区包,用于选择器、滑块之类的平台基础组件。每一个都是独立的原生依赖……多一个要安装的包,多一条要跟进的发布计划,多一个在 SDK 升级时可能出问题的地方。SDK 56 在 @expo/ui/community 下为其中七个提供了原生替代品:

社区包直接替代品
@react-native-community/datetimepicker@expo/ui/community/datetime-picker
@react-native-community/slider@expo/ui/community/slider
react-native-pager-view@expo/ui/community/pager-view
@react-native-picker/picker@expo/ui/community/picker
@react-native-segmented-control/segmented-control@expo/ui/community/segmented-control
@react-native-masked-view/masked-view@expo/ui/community/masked-view
@react-native-menu/menu@expo/ui/community/menu
@gorhom/bottom-sheet@expo/ui/community/bottom-sheet

每个替代品都与它所替代的包近乎 API 兼容。大多数迁移只需改一行 import,不过有些 prop 有所不同,因为 Expo UI 的底层是 SwiftUI 和 Jetpack Compose,而不是 UIKit 和 Android Views:

// Before
import DateTimePicker from '@react-native-community/datetimepicker';

// After
import DateTimePicker from '@expo/ui/community/datetime-picker';

进一步了解 drop-in replacement

最终效果:原生依赖更少,升级路径只有一条,底层还是那套经过审计的基础。

按应用的不同部分选对工具

在 react-dom 里你写 <div><span>,在 @expo/ui 里你写 ColumnRow。道理一样:Expo UI 不是 UI 组件库,也不是什么花哨的设计系统。它只是把 iOS 和 Android 本来就有的原生控件以 React 组件的形式暴露出来。

但 Expo UI 不是工具箱里唯一的工具。Expo 之所以能作为一个框架成立,原因之一就是你可以在同一个应用里混用多套工具:

  • 需要自定义 UI、用自己的设计——从品牌页面到完全定制的设计系统——用 React Native 的 View / Text。它们是不绑定样式方案的 primitive,CSS 风格的 props、flexbox 布局,以及 NativeWind 这类样式库都能用。

  • 需要看起来像原生系统的 UI——设置页、modal、picker、sheet,凡是用户预期「这应该长得像系统自带」的地方——用 Expo UI。在 Host 内部,布局用的是 SwiftUI / Compose 的 primitive(HStack/VStackRow/Column),而不是 Yoga flexbox。

  • 需要全部自己绘制——图表、自定义 shader、定制动画——用 react-native-skia

  • 需要 3D 和 GPU 计算——用 react-native-webgpuTypeGPU

  • 某些界面 Web 生态已经有现成答案(比如 shadcn)——用 DOM components,把 Web 代码跑在 webview 里,在原生端如此,在 Web 端则原样运行。

最重要的是,这些方案是在组件层面组合的。你可以在同一个屏幕、同一棵 view tree 里混用 React Native、Expo UI、Skia、WebGPU / TypeGPU 和 DOM components,做出既通用、又深度原生的体验。

更新后的 showcase 应用

我们把 hot-chocolate 升级到了 SDK 56。这个应用原本只有 SwiftUI 版本,现在有了真正的通用组件,也能跑在 Android 和 Web 上。

Apple TV 和 Android TV

Expo UI 现在同时支持 Apple TV 和 Android TV。这主要归功于 Douglas Lowder 的工作。大部分组件和 API 都支持,只有部分 SwiftUI 和 Compose 原生 API 在 TV 上根本不存在。ExpoUITV 这个应用展示了几乎所有受支持的 API,在 TV 和移动端都能运行。

感谢社区

没有你们,Expo UI 不会走到稳定版。SDK 56 这个周期格外依赖社区的推动。你们提交 issue、和我们一起审查 API、贡献了完整的组件,还修掉了我们甚至还没发现的崩溃。特别感谢:

2hwayoungAKSHAY JADHAVAxelBenjamin KomenBetoChristian WooldridgeDennis MorelloDylanEliot GeversfedeciancagliniGregory MoskaliukHugo ExtrathypnokermitIan BerryIsaiah HamiltonJeroenGJoss MackisonK.Dileepa Thushan PeirisKfir Fitousikimchi-developerLeonardo E. DominguezLièsLoic CHOLLIERLouislucabc2000Petr ChalupaPflaumenbaumRaySam ShubhamShubh Porwalstarsky-nevSuvesh MozaTerijakiThiMalYousof AbouhalawaZhovtonizhko Dmitriy

也感谢每一位参与测试、提交 issue、参加 office hours,或者只是在我们推进 SDK 56 时待在 Discord 里闲聊的人。这个版本有你们的一份功劳,和那些 PR 一样重要。

从哪里开始

npx create-expo-app@latest --template default@sdk-56 —— Expo UI 已经进入默认模板,所以 HostSwitchPicker 都可以直接 import。

如果你现有的项目里已经装了上面那张即插即用表格中的任意一个包,不妨先换掉一个 import 试试。

看看这些示例应用:hot-chocolateExpoUITV

提交 issue、发 PR、来 Discord 找我们聊。稳定不等于完工——它只意味着地基已经足够扎实,可以开始下一轮建设了。

欢迎来到稳定版 Expo UI。现在,去写点原生的东西吧。

来源: Expo Blog← 返回首页