
前段时间,我给自己开发的照片编辑 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 界面图来自实际模拟器截图。文中代码为工程节选或等价精简,接入时需要结合所在项目的状态与资源声明。