- Published on
同一份 JS,两种 UX:跨平台抽象为什么不能假设呈现形态是常量
- Authors

- Name
- Jack Qin
跨平台抽象给我们一个诱人的承诺:写一份代码,到处运行。@expo/ui/swift-ui 的 <BottomSheet> 把这个承诺推到很彻底——它直接桥接到 SwiftUI 的 .sheet(isPresented:) modifier,让你用 JSX 描述一个原生 sheet。但这个承诺藏着一条很少被明说的前提:它假设底层原生原语的"呈现形态"是个常量。一旦这个前提在不同设备类上破裂,"同一份 JS"就会渲染出两种不同的 UX——而你的代码看起来完全没错。
本文不讲某个 sheet 怎么布局,而是想拆清楚这条前提为什么会破:.sheet 的呈现形态不是组件属性,而是 UIKit 按设备类决定的运行时行为;presentationDetents 在 iPhone 上是有效旋钮、在 iPad 上基本是装饰。理解了"呈现形态是平台变量而非组件常量"这一点,你面对的就不只是这个 BottomSheet,而是一整类"跨平台组件在不同设备上静默改变形态"的陷阱。
它实际上是什么:呈现形态由 UIKit 按设备类决定
<BottomSheet> 桥接到 .sheet,而 .sheet 底层是一个 UIModalPresentationStyle——这个 style 不是固定的,UIKit 会根据设备类挑不同的值:
| 平台 | 默认呈现 | 大致尺寸 | presentationDetents |
|---|---|---|---|
| iPhone | .pageSheet 从底部弹出 | 全宽 × fraction × 屏高 | 生效 |
| iPad(竖屏 + 横屏) | .formSheet 居中 | ~580 × ~620 pt(固定) | 基本被忽略 |
| Mac Catalyst / "Designed for iPad on Mac" | .formSheet 居中 | 同样 ~580 pt | 同上 |
所以在 iPad 上,你以为写的那个"占屏 75% 高的底部抽屉"根本不成立:
presentationDetents([{ fraction: 0.75 }])不会让 sheet 占屏 75%——系统把它 clamp 到 form-sheet 尺寸;- sheet 居中出现(水平垂直都居中),不贴底边;
- 拖动关闭仍有效,拖动指示器仍显示;
- iOS 26 Liquid Glass 材质仍会自动应用到 sheet chrome——这正是 iPad 上用
<BottomSheet>而非手搓<Modal>的主要理由。
关键认知是:呈现形态不是你能通过 prop 控制的东西,而是 UIKit 在运行时按设备类替你决定的。presentationDetents 不是失灵了,而是它本就只在 .pageSheet(iPhone)语境下有意义,到了 .formSheet(iPad)语境系统有自己的固定尺寸。把它当成"跨平台统一的高度旋钮",是误解了这个旋钮的作用域。
错误的设计假设,以及它为什么单测抓不到
最常见的错误,是把"我想要底部抽屉"当成布局依据,而不是把"运行时实际是什么形态"当依据:
// 假设 iPad 会把它渲染成 75% 高、全宽
<BottomSheet isPresented={visible}>
<Group modifiers={[presentationDetents([{ fraction: 0.75 }])]}>
<Grid horizontalSpacing={12} verticalSpacing={12}>
{fourColumnRows} {/* 按全屏宽设计的 */}
</Grid>
</Group>
</BottomSheet>
在 iPad 上这会渲染成居中 ~580×620pt 的 sheet,4 列单元格挤成一团。JS 看起来完全没错——它的 bug 不在逻辑层,而在"运行时呈现形态"这个 JS 看不见的维度。这也是为什么单元测试一点忙都帮不上:Vitest 渲染不了 SwiftUI 桥接,关于这个分支的断言只能靠手动 QA。一个"代码正确、设计在运行时崩掉"的 bug,本质上要求你在真实形态里去看它。
正确的做法是:按渲染时的 useWindowDimensions().width 决定布局,并接受"iPad 上它就是个居中 form-sheet"这个事实。
// 按实际窗口宽度选列数,并接受 iPad 上它就是个居中 form-sheet。
const { width: screenWidth } = useWindowDimensions();
const cols = decideColumnCount(
screenWidth >= 768 ? "tablet" : "phone",
screenWidth
);
<BottomSheet isPresented={visible}>
<Group modifiers={[presentationDetents([{ fraction: 0.75 }])]}>
<ScrollView>
<Grid horizontalSpacing={12} verticalSpacing={12}>
{chunkSites(sites, cols).map((row, i) => (
<Grid.Row key={i}>
{row.map((site) =>
site ? (
<SiteCellSUI site={site} ... />
) : (
<Spacer /> // 用空占位补齐最后一行,保持对齐
)
)}
</Grid.Row>
))}
</Grid>
</ScrollView>
</Group>
</BottomSheet>
设计依据的关键转换是:sheet 容器在 iPad 上不管屏幕多大都是 ~580pt 宽,所以要按 ~560pt 的可用水平空间排版,而不是按整个屏幕宽。screenWidth < 768(手机)时把 body 当成一个高的单列;screenWidth >= 768(iPad form-sheet)时,2 列网格能放下,3 列以上在默认 detent 下会很挤。
设计空间与边界:想要更宽的 sheet 怎么办
接受了"iPad 上是 ~580pt form-sheet"之后,自然会问:能不能让它更宽?目前没有文档化的办法让 @expo/ui/swift-ui 的 .sheet 占据 iPad 大半屏。这是一条诚实的边界,绕开它只有两条路,且各有代价:
- 用非 sheet 的呈现(如 RN
<Modal>+<LiquidPanel>,像旧的TabletSiteSwitcher)——代价是失去 chrome 上的系统 Liquid Glass; - 用 SwiftUI 的
.popover(完全不同的 UX)——当前<BottomSheet>不支持。
这正是 trade-off 的具体形态:系统 sheet 给你免费的 Liquid Glass 材质,但把尺寸控制权收走了;手搓 Modal 给你尺寸自由,但 Liquid Glass 要自己想办法。选哪条取决于你更看重哪一头。把这个权衡摆明,比假装"总能调到任意宽度"诚实。
按设备类落到三档结果:
- Good:站点选择器,iPad 的
<BottomSheet>里放 2 列 SwiftUI<Grid>。form-sheet 宽度够放 2 张卡,Liquid Glass 自动有,同一组件也处理手机。 - Base:控制面板(滑块、开关)在 form-sheet 里单列渲染,垂直滚动交给内层 RN
<ScrollView>。和手机一样的 UX,只是居中。 - Bad:以为
presentationDetents([{ fraction: 0.9 }])会让 sheet 在 iPad 上接近全屏,或在默认 form-sheet 里塞 4 列网格——卡片窄到看不清,设计在运行时就坏。
验证:在真实形态里看它
因为这个 bug 活在 JS 不可见的维度,验证必须在能渲染真实形态的环境做:
- sheet 在 iPhone 和 "Designed for iPad on Mac" 上都测过再宣布完成。iPad form-sheet 行为在"只做 iPhone QA"时是隐形的;
- 确认 Liquid Glass 确实在渲染(Mac "Designed for iPad" 能渲染真实的 iOS 26 Liquid Glass,不需要 iOS 26 模拟器);
- 检查长内容(如长站点名)在 form-sheet 宽度下不被截断;
- 记住旧的"居中 Modal +
<LiquidPanel>"模式(TabletSiteSwitcher)是 form-sheet 宽度太受限时的 workaround,别从头重造。
可用原语(Grid、Grid.Row、ScrollView、LazyVStack、BottomSheet)都在 @expo/ui/build/swift-ui/ 下。
可迁移的那一层
抛开 @expo/ui 和 SwiftUI 的具体 API,真正可迁移的认知是:
跨平台抽象统一的是"你写的代码",但它统一不了"代码在每个平台被渲染成的形态"——后者是平台的变量,不是组件的常量。 一份 <BottomSheet> 在 iPhone 是底部抽屉、在 iPad 是居中弹窗,正是这个变量在作怪。任何"一份代码多端渲染"的组件(响应式 web 断点、Flutter 的 adaptive widget、RN 的 platform-specific 默认)都共享这个陷阱:抽象让代码看起来与平台无关,恰恰因此诱使你忘记呈现层仍然在按平台分叉。
设计这类界面时,与其假设"我写的就是用户看到的",不如先问:这个组件在我要支持的每一类设备上,运行时的真实形态分别是什么?我的布局是按真实形态算的,还是按我想要的形态算的? 而且别忘了,单测在这类问题上是哑的——能渲染真实形态的环境(真机、"Designed for iPad on Mac")才是唯一的裁判。