Published on

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

Authors
  • avatar
    Name
    Jack Qin
    Twitter

跨平台抽象给我们一个诱人的承诺:写一份代码,到处运行。@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 大半屏。这是一条诚实的边界,绕开它只有两条路,且各有代价:

  1. 用非 sheet 的呈现(如 RN <Modal> + <LiquidPanel>,像旧的 TabletSiteSwitcher)——代价是失去 chrome 上的系统 Liquid Glass
  2. 用 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,别从头重造。

可用原语(GridGrid.RowScrollViewLazyVStackBottomSheet)都在 @expo/ui/build/swift-ui/ 下。


可迁移的那一层

抛开 @expo/ui 和 SwiftUI 的具体 API,真正可迁移的认知是:

跨平台抽象统一的是"你写的代码",但它统一不了"代码在每个平台被渲染成的形态"——后者是平台的变量,不是组件的常量。 一份 <BottomSheet> 在 iPhone 是底部抽屉、在 iPad 是居中弹窗,正是这个变量在作怪。任何"一份代码多端渲染"的组件(响应式 web 断点、Flutter 的 adaptive widget、RN 的 platform-specific 默认)都共享这个陷阱:抽象让代码看起来与平台无关,恰恰因此诱使你忘记呈现层仍然在按平台分叉

设计这类界面时,与其假设"我写的就是用户看到的",不如先问:这个组件在我要支持的每一类设备上,运行时的真实形态分别是什么?我的布局是按真实形态算的,还是按我想要的形态算的? 而且别忘了,单测在这类问题上是哑的——能渲染真实形态的环境(真机、"Designed for iPad on Mac")才是唯一的裁判。