iPhone Duo 适配实战,Awaking 踩过的 8 个坑

前段时间,我给自己开发的照片编辑 App Awaking 做了一轮 iPhone Duo 适配。最初想到的是画布变大、面板搬到右侧。真正做下来,首页、图库、编辑器、追光仿色、展台详情和导出,每一段流程都有自己的空间问题。

有的问题很直观。三列卡片越过检查器边缘,标题和滑杆看起来总差一点,弹层把照片挤得很小。有的问题藏在过程里。直接横屏打开正常,竖屏打开再旋转就变了样。按钮一直存在,位置却跟着上一次布局跑。还有一次,竖转横直接触发了持续的布局反馈循环。

这篇把这些经过整理出来。先说为什么这样设计,再拆开讲布局、导航、组件和验证,最后给出一份可以带回自己项目的检查清单。

先想清楚,展开后用户能多做什么

Awaking 的主要场景是围绕一张照片持续创作。用户可能先从参考图寻找光色,再试滤镜、调整参数、增加蒙版,最后保存作品或导出。

这里最珍贵的空间是照片本身。用户拖动参数时需要看见变化,切换蒙版时需要知道当前区域,参考图仿色时需要保留比较对象。面板占据多少高度,会直接影响这些判断。

因此,我们给不同空间安排了不同的职责。

空间形态 Awaking 的安排 对应的使用目标
普通 iPhone、Duo 折叠外屏 竖向画布与紧凑底部工具区 保持单手浏览和快速编辑的顺序
Duo 完全展开、窗口纵向 较大的画布与扩展底部区域 同时容纳照片、素材与参数
Duo 完全展开、窗口横向 工具轨、画布与右侧 Inspector 让调参与观察形成并列工作区
部分展开或受限窗口 根据活动分区与可用尺寸分配内容 让主要交互落在可操作区域内

这是 Awaking 的产品选择。其他 App 可以根据阅读、导航或创作任务选择自己的结构。

上图来自开发版本的 Duo 模拟器。照片保持完整可见,左侧承载工具入口,右侧承载当前对象和参数,页级动作进入系统侧边区域。读者看到的每一块空间,都应该能对应到一个正在进行的任务。

我们也重新区分了两种看起来相似的"大屏"。浏览型页面可以增加目录列数、限制文字行宽。编辑型页面则需要围绕画布安排从属工具。首页、图库和精修工作台各自服务不同任务,空间分配也跟着任务走。

Apple 的准备指南强调从当前视图或窗口取得尺寸,用尺寸类别与实际空间判断布局。Duo 展开以后,iPhone App 也可能进入 regular 环境。项目里所有把 regular 直接当成 iPad 的分支,都值得重新检查。Apple 的 Duo 准备指南

一、根窗口和内容区域,分别回答自己的问题

这一轮首先需要整理的是尺寸从哪里来。

照片编辑器通常有多层容器。根窗口包着导航,导航下面有工作区,工作区里还有画布、工具轨和 Inspector。每一层拿到的宽高都可能不同。

如果页面在扣除导航之后再次决定整页结构,导航位置就可能参与下一轮布局选择。我们后来把这两个问题分开处理。

  • 根窗口根据系统铰链状态、尺寸类别和根容器比例,表达当前折叠窗口的布局意图。
  • 工作区根据本地可用区域、遮挡和各部分最小尺寸,分配具体区域。
  • 页面、面板和导航共同消费求解结果。

这是根窗口判定的代码节选。size 来自根 GeometryReader,横向和纵向含义都取当前窗口比例。

swift 复制代码
enum AdaptiveWindowLayout: Equatable {
    enum HingeState: Equatable {
        case unavailable, closed, partiallyOpen, fullyOpen
    }

    case standard
    case compactPhone
    case expandedPhonePortrait
    case expandedPhoneLandscape
    case dividedPhone

    static func resolve(
        size: CGSize,
        hinge: HingeState,
        isRegularWidth: Bool,
        isRegularHeight: Bool
    ) -> Self {
        guard hinge != .unavailable else { return .standard }
        guard hinge != .closed, isRegularWidth else { return .compactPhone }
        guard hinge == .fullyOpen else { return .dividedPhone }
        guard isRegularHeight, size.width > 0, size.height > 0 else {
            return .standard
        }
        return size.width > size.height
            ? .expandedPhoneLandscape
            : .expandedPhonePortrait
    }
}

这份意图通过环境向下传递。系统铰链不可用时,进入常规窗口策略。局部任务卡片和窄弹层也有自己的布局上下文,避免整页展开策略穿透到一个小容器里。

另一个容易混在一起的概念是结构与视觉密度。工作区采用底部面板,并不能直接决定字号、间距和点击区。一个横向很宽但高度不足的窗口,可以回落底部结构,同时保留适合宽窗口的视觉密度。

Awaking 分别持有 WorkspaceMode 与宽度档位,再由同一份 WorkspacePresentation 向下投影。这样,结构切换和控件密度就能各自依据实际空间变化。

窗口变化期间,照片级工具模型、草稿、撤销历史和导航状态由上层持续持有。对于编辑器,旋转前选中的照片和编辑目标,是下一种布局的起点。

二、安全区域之外,还要处理局部保留区域

Safe Area 之外,Duo 还带来了需要单独理解的局部区域。

division 描述会分隔内容的区域,occlusion 描述局部遮挡。自定义画布和工具面板需要根据这些区域安排内容。Awaking 在统一入口取得它们,并把 frame 保留在当前 GeometryProxy 的本地坐标系内。Apple 对保留区域与自适应容器的介绍

swift 复制代码
static func resolve(
    geometry: GeometryProxy,
    environment: EnvironmentValues
) -> Self {
    if #available(iOS 27.1, macOS 27.1, *) {
        return Self(
            availableSize: geometry.size,
            horizontalSizeClass: environment.horizontalSizeClass,
            verticalBarEdge: environment.toolbarVerticalEdge,
            divisionRegions: geometry.reservedRegions(kind: .division)
                .filter(\.isActive).map(\.frame),
            occlusionRegions: geometry.reservedRegions(kind: .occlusion)
                .filter(\.isActive).map(\.frame),
            windowLayout: environment.adaptiveWindowLayout
        )
    }
    return Self(
        availableSize: geometry.size,
        horizontalSizeClass: environment.horizontalSizeClass
    )
}

这里有两个实际踩过的坑。

第一个是重复扣安全区。外层容器已经提供安全内容尺寸,内层继续减 inset,画布就会再缩一圈。我们给布局上下文明确了输入合同,由系统容器处理安全区,内部求解使用交进来的实际区域。

第二个是边距叠加。系统侧栏相邻位置已经有内容间距,页面继续叠加完整 padding,看起来就会一边松、一边紧。

我们在这套布局中核对到的系统相邻间距是 12pt。目标视觉间距为 16pt 时,对应侧由 App 补 4pt。这个值属于当前布局的测量与实现配置,接入其他项目时应核对自己的容器。

还要把侧栏的 leading、trailing 分开处理。左右分屏会改变相邻关系,padding 的计算与网格宽度的计算需要使用同一组边距。

三、导航换位置以后,工具项身份也要说清楚

编辑器里的保存、返回、分享,既有导航含义,又有启用条件、确认流程和操作顺序。Duo 适配需要把这些业务动作投影到合适的位置。

Awaking 的展开内屏导航安排如下。

当前工作区 返回或关闭 其他页级动作
纵向,或横向但没有 Inspector 顶部导航带左侧 同一导航带右侧
横向 Inspector 的常规任务页 右侧状态区下方 右侧底部锚定的操作组
横向 Inspector 的展台详情 右侧状态区下方 作品信息标题右侧
横向 Inspector 的照片整理 右侧状态区下方 整理建议标题右侧

后两项是我们根据页面任务确定的例外,由页面配置声明,公共策略解析。页面提供业务动作,统一宿主安排位置。

迁移过程中出现过一个很典型的回归。首轮调整后,操作按钮转到竖屏时停在底部,再转横屏时又留在顶部。按钮的可用状态正确,布局位置却错误。

我们随后把顶部和侧栏工具项拆成固定身份,各自绑定固定的 placement 与 axis behavior。外层导航宿主保持稳定,旋转时切换对应的工具项集合。下面是精简后的组织方式,业务按钮内容与回调由页面传入。

swift 复制代码
.toolbar {
    if isVertical {
        ToolbarItem(id: "navigation_side_back", placement: .cancellationAction) {
            backButton
        }
        .axisBehavior(.verticalPreferred)

        ForEach(sideItems) { item in
            ToolbarItem(id: "navigation_side_\(item.id)", placement: .bottomBar) {
                item.content
            }
            .axisBehavior(.verticalPreferred)
            .visibilityPriority(item.isPrimary ? .high : .automatic)
        }
    } else {
        ToolbarItem(id: "navigation_top_back", placement: .topBarLeading) {
            backButton
        }
        .axisBehavior(.horizontalOnly)

        ForEach(topItems) { item in
            ToolbarItem(id: "navigation_top_\(item.id)", placement: .topBarTrailing) {
                item.content
            }
            .axisBehavior(.horizontalOnly)
        }
    }
}

这段中的 isVertical 表示已经解析出的侧边导航呈现。页面无需再读一次物理方向来决定去向。axisBehavior 等新 API 的使用放在 iOS 27.1 可用分支内,旧系统继续走对应的兼容入口。Apple 的工具栏轴向说明

还有几处需要一起检查。

  • 无标题的编辑工作台,按实际内容决定是否注册 principal 工具项。
  • 保存按钮在工具栏内部观察真实编辑状态,让首次加载完成和编辑结束及时更新可用性。
  • 顶部或侧栏的一次点击,继续经过原有遮罩消费、退出确认和保存流程。
  • 空间变小时,给关键动作表达可见优先级,并检查系统溢出菜单。
  • 验证按钮时同时检查唯一性、位置、可点击性与返回目标。

Apple 的竖向栏设计说明值得对照阅读,尤其是动作顺序、自定义内容的轴向表达和溢出优先级。页面里有多个独立按钮时,逐项表达这些语义会更清楚。Apple 的 Duo 导航与工具栏介绍

四、一次旋转卡死,来自布局和导航之间的反馈

这一轮最费排查时间的问题,发生在精修页由竖向转为横向时。

现场日志持续出现 Observation feedback loop 与导航约束冲突。进一步采样后,我们在相同导航尺寸下观察到 Inspector 交替进入和退出,随后取得了工作区求解器的两组连续输入。

阶段 可用区域 当时的求解结果
顶部导航 867 × 553pt 足够安排工具轨、画布与 Inspector
侧边导航 867 × 611pt,右上出现局部遮挡 最大面积策略选到 807 × 611pt 的左侧区域

中宽工作区的预算是 56pt 工具轨、至少 480pt 画布、280pt Inspector。

text 复制代码
807 - 56 - 480 = 271

只剩 271pt,Inspector 退出。退出后导航回到顶部,输入又变回第一行,于是再次进入 Inspector。

右上角遮挡下面,其实还有一个 867 × 553pt 的安全矩形。它的面积略小,但能满足这套工作区约束。原来的算法优先比较面积,漏掉了候选区域对布局的支持能力。

修复后,候选区域先经过同一份 Inspector 可行性判断。有可行候选时在其中比较面积,空间确实不足时再正常回落。筛选的核心代码很短。

swift 复制代码
let preferred = preferredRegion.map { candidates.filter($0) } ?? []
return (preferred.isEmpty ? candidates : preferred).max {
    $0.width * $0.height < $1.width * $1.height
} ?? available

这里的 preferredRegion 复用工作区现有的尺寸判定。整个修复的意义在于,让区域选择和结构选择遵守同一组约束。

我们也把现场连续输入写进了回归测试。测试根据上一轮的 Inspector 状态选择下一轮输入,连续求解八轮,每轮检查结构、最小画布、Inspector 宽度和遮挡关系。

swift 复制代码
for _ in 0..<8 {
    let context = presentation?.panelPlacement == .inspector ? side : top
    let resolved = WorkspaceLayoutMetrics.presentation(
        for: context,
        reservedLeadingWidth: 80,
        current: presentation
    )
    XCTAssertEqual(resolved.panelPlacement, .inspector)
    XCTAssertGreaterThanOrEqual(
        resolved.primaryRegion.width - resolved.toolRailWidth,
        480
    )
    XCTAssertEqual(resolved.inspectorWidth, 280)
    if !context.occlusionRegions.isEmpty {
        XCTAssertFalse(resolved.contentRegion.intersects(camera))
    }
    presentation = resolved
}

传入的 reservedLeadingWidth 表达工具轨占位需求,求解器再根据当前工作区得到实际轨宽。测试还包含没有可行 Inspector 区域时的回落,保证选择始终尊重遮挡。

这次经验也改变了我们的验证方式。单独验证某个宽高的布局结果,还需要补上导航、遮挡和工作区之间的连续输入回放。布局输出能够影响下一轮输入时,收敛性就是一个可测试的行为。

五、三列目录溢出,要一起核对卡片和内部预览

接下来是边框与特效面板。初看只是右侧多出了一截,实际牵涉到检查器宽度、页面边距、列间距和卡片自身宽度。

这两个目录最终选择保持三列,卡片随可用内容宽度均分。这样能保持它们在 Inspector 里的浏览节奏。图库和预设目录则根据自己的内容、宽度与文字长度选择列数。

计算从实际 Inspector 宽度开始,先减去本次绘制使用的左右 padding,再减去两道列间距。

swift 复制代码
static func inspectorCatalogItemWidth(contentWidth: CGFloat) -> CGFloat {
    let columns = CGFloat(inspectorCatalogColumnCount)
    let availableWidth = max(
        0,
        contentWidth - inspectorCatalogColumnSpacing * (columns - 1)
    )
    return floor(availableWidth / columns)
}

外层网格变窄以后,边框示例、模板预览和卡片高度也需要消费新的卡片尺寸。只把 GridItem 改成 flexible,内部预览仍沿用固定宽度,溢出就可能继续发生。

列数也要跟页面职责一起判断。主画布如何避开活动折叠区域,由上层工作区负责。某个已经分配好的 Inspector 内部采用三列,是这块区域里的目录设计。整屏分区与局部卡片网格分别需要自己的边界。

六、滑杆对齐,应该对齐到实际画出来的轨道

窄 Inspector 里,原来的标题、滑杆、数值横排很容易互相挤占。我们把普通参数改成两行,上面左标题、右数值,下面使用整行轨道。

结构改完后,标题和数值框还是有一点不齐。检查后发现,原生 slider 的容器边界和可见轨道端点有差异,自定义滑块与范围文字也会参与占位。

我们让 UIKit 控件回传真实轨道的左右 inset,由 SwiftUI 标题行消费。下面这段来自轨道层刷新入口。

swift 复制代码
let trackRect = trackRect(forBounds: bounds)
let minimumX = thumbCenterX(for: minimumValue, trackRect: trackRect)
let maximumX = thumbCenterX(for: maximumValue, trackRect: trackRect)
let trackMinimumX = min(minimumX, maximumX)
let trackMaximumX = max(minimumX, maximumX)
let insets = UIEdgeInsets(
    top: 0,
    left: max(0, trackMinimumX - bounds.minX),
    bottom: 0,
    right: max(0, bounds.maxX - trackMaximumX)
)
if reportedTrackInsets != insets {
    reportedTrackInsets = insets
    onTrackInsetsChanged?(insets)
}

代码按真实绘制端点取左右边界,标题行使用同一组 inset。桥接到 SwiftUI 的更新异步发布,并核对当前仍是同一组几何值。

这里还要控制更新频率。轨道宽度发生变化时回传,单纯拖动数值时沿用已有几何。否则一个用于对齐的修复,也可能把参数变化扩散成持续的布局更新。

我们随后继续调整了标题行的视觉距离,并检查数值输入、双击复位和控件热区。一个组件的布局,包含可见内容、命中区域和它原有的交互语义。

七、加载跳动,要沿着视图身份和滚动请求查

滤镜和蒙版目录还出现过加载期间的位置跳动。标题和上方控制区比较稳定,下面的目录却会移动。

代码里有两条值得追的链路。

一条是在源图加载期间切换为独立骨架网格,源图就绪后重新建立真实目录和滚动容器。另一条是单分类纵向目录挂载时,又执行了一次初始 scrollTo。

这次处理让真实目录与卡片保持挂载,图片在固定尺寸的占位区域中升级,同时整理首次定位条件。分类切换、用户点击居中和手动滚动,继续分别表达自己的滚动意图。

这对照片编辑器尤其重要。用户点中一个滤镜后,紧接着还可能比较相邻效果。目录的位置本身也是编辑上下文的一部分。

同样的思路适用于缩略图。像素预算随着展示尺寸升级时,先保留上一张有效图,拿到新结果再更新,并校验照片来源与 revision。适配扩大了展示空间,也扩大了图片请求,加载链路需要一起检查。

这部分目前有代码与离线策略回归依据,最终跳动幅度和滚动手感仍需要在对应运行环境复查。

八、弹层高度,要从外部窗口取得预算

色彩灵感详情经历了好几轮调整。我们先尝试利用横向空间安排照片和说明,后来根据实际观看体验,收敛到有界的单列照片详情。

这里有两个很具体的问题。

页眉重复占用了照片空间

任务卡片已有顶部与关闭区,内部又套了一层整页导航,标题和关闭按钮整体下移,照片可用高度随之减少。

处理时需要先确定容器层级。整页导航服务整页,局部卡片服务当前任务,关闭接力与内容布局分别归对应宿主。明确这一点以后,标题、照片、说明和主按钮才能共享清楚的高度预算。

同一方向,通过两种路径打开,结果不同

另一个问题更容易漏测。横向直接打开看起来正确,从纵向打开再旋转,弹层高度却延续了上一方向。

我们核对到两处来源。初始宽度参与了容器选择,详情高度又读取了 Sheet 自身的测量值。旋转过程中,这些结果可能把旧布局带进下一次计算。

最终,移动端这类详情统一由系统 Sheet 承载,外部窗口提供当前可用高度,内部按预算 fit 照片。详情里的阅读顺序固定为标题、照片、说明与主按钮。

两张图来自已有开发版本的模拟器记录,展示同一份"暖桌食光"详情。素材由 Stelios 提供,界面保留了作者署名。

后来我们的检查路径增加了四种组合。横向直接打开、纵向直接打开、横转纵、纵转横。还会从首页和图库分别进入,比较最终同方向的容器、关闭按钮、照片比例和主操作位置。

适配推进顺序,以及怎样证明它真的成立

回看这一轮,先理解系统的空间模型,再从既有项目的尺寸来源和生命周期逐步落地,能让改造更容易验证。窗口、安全区和导航之间的关系,需要和每个页面正在服务的任务一起考虑。

放到 Awaking,比较有效的顺序是先确定各页面的任务和展开收益,再统一空间输入与布局结果,然后接入导航策略,最后逐个处理目录、参数、加载与弹层。每一轮修复都沿真实入口检查,兼顾直接进入和状态变化之后的结果。

现在我会把验收拆成三组。

验证层级 需要回答的问题
纯值与策略回归 区域是否越界、尺寸预算是否成立、连续输入能否收敛、空间不足如何回落
完整集成构建 当前 App、组件与新系统 API 是否能一起编译,旧系统分支是否完整
运行态检查 按钮位置和点击是否正确,旋转后状态是否连续,滚动与照片观感是否成立

本文的旋转反馈案例,在修复时完成了同一 Duo 模拟器上的精修与追色路径复验,对应观察窗口中的反馈循环和导航约束冲突为零。整理文章时,也重新运行了当前源码的遮挡收敛离线回归,10 项通过、0 失败。

灵感详情完成过直接打开与旋转路径的模拟器检查。滑杆最终触感、目录加载稳定性,以及全部页面的英文、大字号和设备组合,仍需要继续验收。本文图片来自开发版本,相关记录分别说明了验证范围。

可以带回自己项目的检查清单如下。

  • 尺寸来自当前窗口或视图,并在变化期间持续更新。
  • 根窗口、导航后的内容区域与局部弹层拥有明确的尺寸来源。
  • regular、设备类型、布局结构和视觉密度分别表达各自含义。
  • 安全区只由约定层处理一次,局部保留区域使用一致坐标。
  • 导航位置变化后,按钮身份、顺序、唯一性和业务动作仍然对应。
  • 连续布局输入能够收敛,最小内容尺寸与遮挡约束同时成立。
  • 网格宽度、实际 padding 和内部图片尺寸使用同一份预算。
  • 参数组件分别检查视觉端点、触控热区与更新频率。
  • 加载前后检查卡片身份、滚动位置、缩略图来源和过期结果。
  • 同方向分别验证直接打开与旋转到达,携带草稿继续完整流程。

写在最后

做 Awaking 的过程中,Duo 适配让我们重新检查了照片与工具的关系。空间充足时,把观察和调参并列起来。空间收紧时,继续承接当前照片、编辑目标和草稿。每次重排都需要解释它给正在创作的人带来了什么。

如果你也在开发画布、创作工具或内容编辑器,希望这些实际问题能帮你少走一些弯路。

也借这篇介绍一下我正在开发的 Awaking。它围绕照片创作提供参考图仿色、滤镜、调色、蒙版与作品保存等能力。可以拿一张自己熟悉的照片,试试参考图带来的光色方向,再进入精修比较细节。本文的 Duo 展示基于开发版本,体验时以商店当前版本为准。在 App Store 体验 Awaking

封面为 AI 生成的工程概念图,布局关系与组件示意图按本文实现绘制,App 界面图来自实际模拟器截图。文中代码为工程节选或等价精简,接入时需要结合所在项目的状态与资源声明。

相关推荐
跨境数据猎手5 小时前
HTTP 响应体编码与压缩:识别、解码
网络协议·http·ios
也非非也5 小时前
iPhone 中国区 AI 落地:Apple 与阿里千问合作的技术观察
大数据·人工智能·ios·agent·iphone·智子
茶底世界之下7 小时前
同一片泳池,清透还是奶油胶片?用 Awaking 做两版成片
ios
xbzb8 小时前
Swift 对象存储建环实验 + Ceilometer 计量:两个收尾服务
swift·ring·实验·ceilometer·象存储·建环
2501_916008899 小时前
iOS App 图标、启动屏和多语言搭配 方法,上架前使用KXBuild一次做好 App 图标
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
2501_915918419 小时前
iOS 推送通知怎么实现,从 APNs p8 密钥到 iOS 真机收到第一条推送
vscode·ios·objective-c·个人开发·swift·敏捷流程
东坡肘子9 小时前
快速消失的硬盘容量 -- 肘子的 Swift 周报 #160
人工智能·swiftui·swift
茶底世界之下1 天前
雨夜街角裁成方图,人物与冷暖光还能共存吗?
ios
youdexiang1 天前
IOS录音APP拍照记录能和音频同步吗
ios·音视频