为折叠屏 iPhone Duo 做准备:iOS 应用需要重构的五个界面假设

2026-09-25 21 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

折叠屏 iPhone Duo 带来的挑战,不只是让现有界面在更宽的屏幕上显示。应用可能在外屏、展开后的内屏以及部分折叠状态之间切换,窗口尺寸、宽高比、安全区域和交互方式都可能动态变化。真正需要调整的,是代码中那些默认设备形态固定不变的假设。

在公开 SDK 提供专门的折叠姿态 API 之前,开发者不应猜测设备型号或铰链参数。更可靠的策略是:根据当前可用空间决定布局,让导航、工具栏、相机预览和底部控件能够在窗口变化时重新组织。

不要识别设备,要响应容器

很多旧代码会读取 UIScreen.main.bounds,然后根据某几个已知分辨率选择布局。这种方案在多窗口、旋转、外接显示器和折叠设备上都很脆弱,因为屏幕尺寸不等于当前视图真正获得的空间。

更稳妥的判断依据包括:

  • SwiftUI 的容器尺寸和 horizontalSizeClass
  • UIKit 的 traitCollection 与 view.safeAreaInsets
  • 内容本身需要的最小宽度
  • 动态字体、语言和辅助功能设置

不要把“展开态”简单等同于某个设备型号。可以把它定义为“当前宽度足以同时展示列表和详情”,把“外屏态”定义为“当前空间只适合单列导航”。这样即使最终硬件尺寸与预期不同,界面仍然能够工作。

场景 推荐布局 需要验证的重点
较窄外屏 单列、逐级进入详情 标题截断、返回路径、底部按钮
展开内屏 列表与详情并列 最大内容宽度、空白区域、工具栏归属
部分折叠或特殊宽高比 按实时容器重新排版 遮挡、滚动位置、状态保留
状态切换中 尽量保留选择和输入 不重复请求、不重建业务状态

一个可直接改造的 SwiftUI 自适应导航示例

下面的示例不检测设备名称,也不假设存在某个折叠角度 API。它只根据当前容器宽度和 size class,在单列导航与双栏导航之间切换。

可以新建一个 SwiftUI App 项目,将 App 文件替换为以下内容。示例要求 iOS 16 或更高版本:

import SwiftUI

@main
struct DuoReadyApp: App {
    var body: some Scene {
        WindowGroup {
            AdaptiveCatalogView()
        }
    }
}

struct CatalogItem: Identifiable, Hashable {
    let id = UUID()
    let title: String
    let detail: String
}

struct AdaptiveCatalogView: View {
    private static let items = [
        CatalogItem(title: "相机", detail: "检查预览裁剪、旋转和权限状态。"),
        CatalogItem(title: "地图", detail: "验证列表与地图在宽屏下的协作。"),
        CatalogItem(title: "编辑器", detail: "确保键盘出现后主要操作仍然可见。")
    ]

    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    @State private var selection: CatalogItem.ID?

    var body: some View {
        GeometryReader { proxy in
            let showTwoColumns = horizontalSizeClass == .regular && proxy.size.width >= 700

            Group {
                if showTwoColumns {
                    NavigationSplitView {
                        List(Self.items, selection: $selection) { item in
                            Text(item.title)
                                .tag(item.id)
                        }
                        .navigationTitle("功能")
                    } detail: {
                        if let item = selectedItem {
                            DetailView(item: item)
                        } else {
                            ContentUnavailableView(
                                "请选择一个功能",
                                systemImage: "rectangle.split.2x1"
                            )
                        }
                    }
                } else {
                    NavigationStack {
                        List(Self.items) { item in
                            NavigationLink(value: item) {
                                Text(item.title)
                            }
                        }
                        .navigationTitle("功能")
                        .navigationDestination(for: CatalogItem.self) { item in
                            DetailView(item: item)
                        }
                    }
                }
            }
            .safeAreaInset(edge: .bottom) {
                BottomActionBar()
            }
            .animation(.snappy, value: showTwoColumns)
        }
    }

    private var selectedItem: CatalogItem? {
        Self.items.first { $0.id == selection }
    }
}

struct DetailView: View {
    let item: CatalogItem

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                Text(item.title)
                    .font(.largeTitle.bold())
                Text(item.detail)
                    .font(.body)
                Text("改变窗口宽度,观察导航结构如何切换。")
                    .foregroundStyle(.secondary)
            }
            .frame(maxWidth: 720, alignment: .leading)
            .padding()
        }
        .navigationTitle(item.title)
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct BottomActionBar: View {
    var body: some View {
        Button("创建新项目", systemImage: "plus") {
            print("Create tapped")
        }
        .buttonStyle(.borderedProminent)
        .frame(maxWidth: .infinity)
        .padding()
        .background(.bar)
    }
}

这个例子体现了几个重要原则:

  • 选择状态存放在布局分支之外,切换单栏和双栏时不会丢失。
  • 双栏是否出现由可用宽度决定,而不是由设备名称决定。
  • 详情内容设置了最大宽度,避免在大内屏上被无限拉长。
  • 底部操作使用 safeAreaInset 占据安全区域,而不是写死底部间距。

实际项目中,不应因为布局切换而重新创建网络请求、编辑草稿或相机会话。将这些状态放进 ObservableObject、@Observable 模型或更高层的业务容器中,视图只负责选择布局。

导航和工具栏需要重新分配,而不只是放大

从窄外屏切换到大内屏后,原本位于导航栏右侧的按钮可能离主要内容过远;反过来,把桌面式三栏界面压进外屏也会造成标题和按钮互相争抢空间。

可以按以下规则重新审视导航:

  • 外屏保留一个明确的主路径,次要操作放入菜单。
  • 内屏优先使用 NavigationSplitView 展示主从关系,但不要强制始终显示多栏。
  • 工具栏操作应属于当前内容,而不是机械地固定在整个窗口最外侧。
  • 布局切换后保留当前选择、滚动位置和未提交输入。
  • 不要依赖左上角或右下角永远可触达;展开后的设备可能改变单手操作范围。

如果应用同时支持 iPad,多数工作可以先在 iPad 分屏和可调整窗口中完成。折叠设备暴露的是同一类根本问题:窗口不是常量。

相机预览不能假设固定方向和固定裁剪

相机类应用还要处理镜头位置、设备方向、预览比例和折叠状态变化。即使没有新的专用 API,也应确保预览层始终跟随宿主视图,而不是只在初始化时设置一次 frame。

下面是一个可嵌入 UIKit 相机实现的预览视图。它不包含权限申请和采集会话配置,只负责让 AVCaptureVideoPreviewLayer 跟随容器变化:

import AVFoundation
import UIKit

final class CameraPreviewView: UIView {
    override class var layerClass: AnyClass {
        AVCaptureVideoPreviewLayer.self
    }

    var previewLayer: AVCaptureVideoPreviewLayer {
        guard let layer = layer as? AVCaptureVideoPreviewLayer else {
            fatalError("Unexpected backing layer")
        }
        return layer
    }

    func attach(session: AVCaptureSession) {
        previewLayer.session = session
        previewLayer.videoGravity = .resizeAspectFill
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        previewLayer.frame = bounds
    }
}

使用 .resizeAspectFill 时,画面边缘可能被裁剪,因此拍照应用不能假设预览中所有区域都会出现在最终照片里。二维码扫描、文档识别等功能还需要把识别框坐标转换到预览层坐标,并在每次尺寸变化后更新覆盖层。

相机测试至少应覆盖:

  • 窄屏与展开宽屏之间切换时,采集会话是否被重复启动。
  • 方向或宽高比变化后,预览是否拉伸、旋转错误或留下黑边。
  • 快门、变焦和关闭按钮是否位于安全区域内。
  • 应用进入后台或窗口失去活动状态时,是否正确暂停资源占用。
  • 权限提示返回后,布局和会话状态是否仍然一致。

把保留区域视为动态输入

新形态设备可能引入系统手势区、摄像头区域、圆角或其他不可交互区域。无论最终形态如何,应用都不应该使用固定的顶部、底部或侧边补偿值。

普通内容应默认尊重 safe area。只有背景、视频或装饰元素适合延伸到安全区域之外,按钮、文本输入框和关键状态信息仍应放在可交互区域内。尤其要谨慎使用:

.ignoresSafeArea()

它适合全屏背景,却不应直接套在整个交互层上。需要自定义悬浮控件时,应读取当前容器的 safe area,并在尺寸变化时重新布局。

可以先在代码库中查找常见的固定设备假设。安装 ripgrep 后,在项目根目录运行:

rg 'UIScreen\.main|statusBarFrame|keyWindow|ignoresSafeArea|UIDevice\.current' \
  --glob '*.swift' .

这些匹配不一定都是错误,但值得逐项确认:代码究竟需要的是物理屏幕信息,还是当前窗口和视图的信息?

上线前的改造清单

不必等待专用模拟器或正式硬件才开始准备。可以先完成以下工作:

  • 删除基于设备型号和固定分辨率的布局分支。
  • 在窄、宽、矮以及特殊宽高比窗口中测试核心流程。
  • 确保导航结构变化不会清空业务状态。
  • 让工具栏和主要操作跟随内容,而不是跟随屏幕边缘。
  • 审核所有 ignoresSafeArea、绝对坐标和固定底部间距。
  • 验证相机、地图、视频和画布在运行时尺寸变化后的行为。
  • 为未来可能出现的折叠姿态 API 建立独立能力层,不把平台细节散落到业务视图中。

最值得提前投入的并不是猜测 iPhone Duo 的具体像素或铰链位置,而是让应用真正接受“窗口可以随时变化”这一事实。做到这一点,应用不仅更容易适配折叠屏,也会同时改善 iPad、多窗口、横屏和辅助功能体验。


相关推荐