我的日记应用 Lanternly 有两套界面框架。iPhone 上底部是标签栏,iPad 上左侧是基于 NavigationSplitView 的侧边栏。在两者之间做选择的只有一行代码:if UIDevice.current.userInterfaceIdiom == .pad。到了 iPhone Duo,最先出问题的就是这一行。Duo 是 iPhone,两块屏幕上的 idiom 都是 .phone,于是 7.6 英寸的内屏会得到一条被拉宽的手机标签栏。
这篇文章讲 iPhone Duo 开发者适配的一次实战:用 SwiftUI 按窗口宽度选择界面框架,并且在折叠或旋转设备时不丢失当前打开的页面。所有内容都在 Xcode 27.1 RC 和 iOS 27.1 的 Duo 模拟器上验证过。整体改动我在 iPhone Duo 适配指南里讲过,这里只谈一个应用的具体做法。
为什么 idiom 不再能回答"手机还是平板"#
Duo 有两块屏幕。外屏和普通 iPhone 一样,竖屏时是 compact × regular。内屏无论什么方向都是 regular × regular。但两块屏幕的 idiom 相同。所以"显示哪套框架"这个问题不能问设备,而要问窗口:此刻横向有多少空间。
这正是 SwiftUI 的 horizontal size class(@Environment(\.horizontalSizeClass))提供的信息。它会在折叠、旋转和分屏时变化,SwiftUI 会自动重绘依赖它的视图。
iPhone Duo 开发者适配的框架规则#
我把判断集中到一个类型里,免得散落在各个页面:
import SwiftUI
enum ShellLayout {
static func usesSplit(horizontalSizeClass: UserInterfaceSizeClass?) -> Bool {
UIDevice.current.userInterfaceIdiom == .pad || horizontalSizeClass == .regular
}
}宽窗口(Duo 内屏、横屏的 iPhone Pro Max)使用侧边栏;窄窗口(Duo 外屏、分屏时较窄的一侧)使用标签栏。
idiom 只在一个地方保留下来,作为 iPad 的例外。在 Duo 之前,iPad 一直使用侧边栏,包括窄分屏,那时 NavigationSplitView 会自己折叠成堆栈。如果每次窗口变窄就把 iPad 切到标签栏,就等于为了一台毫不相关的设备改掉一个本来工作正常的行为。所以规则是:"iPad,或者宽窗口"。
在 Mac 上根本不存在这个问题。那里应用有自己的框架 MacRootView,用侧边栏加每个分区一个导航堆栈,所以整个框架选择都放在 #if !os(macOS) 里。Mac 窗口再窄也不会出现标签栏。这是有意为之:Mac 窗口底部出现标签栏会显得格格不入。
应用的根视图现在从环境中读取 size class:
struct RootView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var shellSelection = ShellSelection()
var body: some View {
Group {
if ShellLayout.usesSplit(horizontalSizeClass: horizontalSizeClass) {
PadRootView() // 带侧边栏的 NavigationSplitView
} else {
MainTabView() // 标签栏
}
}
.environment(shellSelection)
}
}主要的副作用是:折叠时一套框架被销毁,另一套从零创建。前一套 @State 里的东西全部丢失。原本在"日历"页的人,展开后会回到"今天"。
让当前页面在折叠后保留下来#
每套框架都有自己的 selection,类型也不同:标签栏有 4 个标签,侧边栏有 9 个分区。唯一共享的是"当前打开的是什么",用字符串保存:
import Observation
@Observable
final class ShellSelection {
var section: String?
}nil 表示"还没有显示过任何框架"。这一点很重要:首次启动时,新框架从自己的默认值开始,不会覆盖调试用的启动参数,也不会覆盖从小组件跳转过来的深层链接。
每套框架在切换分区时写入这个对象,出现时再读取:
struct MainTabView: View {
@Environment(ShellSelection.self) private var shell: ShellSelection?
@State private var selection: AppTab = .today
var body: some View {
content
.onAppear(perform: restoreSection)
.onChange(of: selection) { _, new in shell?.section = new.rawValue }
}
private func restoreSection() {
guard let raw = shell?.section else { return }
// "日记本"这类分区在标签栏里没有,就回到"今天"
selection = AppTab(rawValue: raw) ?? .today
}
}AppTab 和侧边栏分区的原始值相同(today、entries、calendar、luna),所以字符串可以直接传递,不需要对照表。环境值声明为可选(ShellSelection?),预览无需额外配置也能运行。
为什么不用 @SceneStorage?它会在多次启动之间也保存分区。那样应用会打开到上次的页面而不是"今天",产品的行为就变了。我需要的只是场景存续期间的记忆。
导航栏跟随框架,而不是设备#
页面内部还藏着一处 idiom 判断。"今天""日历"和"聊天"在 iPhone 上会隐藏系统导航栏,因为它们自己绘制标题。iPad 上则需要导航栏,里面有收起侧边栏的按钮。以前是这样判断的:
.toolbar(UIDevice.current.userInterfaceIdiom == .pad ? .automatic : .hidden, for: .navigationBar)在 Duo 内屏上,这个页面显示在侧边栏框架里,却以为自己在手机上,于是把按钮藏了起来。正确的问题是"我显示在哪套框架里"。回答它的是一个专用的环境键,由侧边栏框架来设置:
private struct SplitShellKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var isSplitShell: Bool {
get { self[SplitShellKey.self] }
set { self[SplitShellKey.self] = newValue }
}
}
// 在 PadRootView 中:
NavigationSplitView { sidebar } detail: {
detail(for: selection)
.environment(\.isSplitShell, true)
}
// 在页面中:
@Environment(\.isSplitShell) private var isSplitShell
// ...
.toolbar(isSplitShell ? .automatic : .hidden, for: .navigationBar)这样一来,代码里只剩 ShellLayout 中的一处 idiom 判断,其余所有决定都取决于窗口或框架。
不用手动操作也能验证框架切换#
验证即时切换,用 iPhone Pro Max 很方便:横屏时它的宽度同样是 regular。命令行无法旋转模拟器,所以我加了一个调试参数,通过 requestGeometryUpdate 请求场景旋转:
#if DEBUG
static func applyOrientationFlag() {
let args = CommandLine.arguments
guard let i = args.firstIndex(of: "-orientation"), i + 1 < args.count else { return }
for (step, name) in args[i + 1].split(separator: ",").enumerated() {
let mask: UIInterfaceOrientationMask = name == "portrait" ? .portrait : .landscapeRight
DispatchQueue.main.asyncAfter(deadline: .now() + Double(step + 1) * 4) {
for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
}
}
}
}
#endif带 -orientation landscape,portrait 启动后,屏幕会在第 4 秒和第 8 秒旋转。我测试时,"记录"页经历了"标签栏 → 侧边栏 → 标签栏",始终保持选中。在 iPadOS 27 的窗口模式下,系统会忽略这个请求,不过 iPad 上框架本来就不该变化。
Duo 的折叠和展开无法通过命令行触发,只能用 DeviceHub 的按钮。我是怎么处理的,写在在模拟器中测试 iPhone Duo 这篇文章里。
已经完成的和尚未完成的#
在 iOS 27.1 的 Duo 模拟器上,我验证了双向的即时折叠与展开、两块屏幕的旋转,以及书本姿态。当前页面都能保留。iPhone 18 Pro Max 旋转时会切换框架,iPad Pro 13 一切照旧。
没有实际验证的是 iPhone Duo 分屏同时运行两个应用的情况。内屏的一半算作窄窗口,按规则会显示标签栏,和外屏走的是同一条路径。但这只是推断,不是测试:在模拟器里我没能用鼠标完成从程序坞拖出应用的手势。
框架只是一半的工作。另一半出现在具体页面上:竖向系统栏下的标签栏、落在折痕上的提示、横跨铰链的聊天输入框。这些问题写在另一篇关于 iPhone Duo 适配问题的文章里。



