前言
同一款播放器,在窄窗口里可能需要上下排列画面和队列;获得更宽空间时,可以让它们左右并排。到了折叠设备,问题又多了一层:内屏即使尺寸没有明显变化,中央的折叠区域也可能影响控件应该待在哪里。
ArrangementView处理的正是两块相关内容如何共同适应环境的问题。理解它,不能只比较三个 API 名称,还要看同一份内容在外屏、全展开内屏、半展开内屏中的位置、尺寸和显示方式。
本文以 Apple 的 Strike a pose with adaptive layouts on iPhone Duo 为线索,使用一个风景播放器演示:蓝色 A 始终代表风景内容,橙色 B 始终代表队列或控制台。两块区域的颜色和业务身份保持稳定,布局变化因而更容易观察。
更新日期:2026 年 9 月 25 日。本文对应当前
ArrangementViewTest工程的新界面,使用 Xcode 27.1(27A9269)编译。外屏三种模式已有运行截图;内屏全展开、半展开及旋转尚未完成实测。后文会分别说明实现、已观察结果和待验证预期。
一、ArrangementView 负责两块内容的关系
ArrangementView接收primary与secondary。系统根据可用空间、Size Class 和硬件环境决定如何安排内容。默认样式为.automatic,当前解析为 split arrangement;官方文档标注 iOS / iPadOS 27.1 起可用。ArrangementView 文档
本文把职责分成三层:
| 层级 | 本例中的工作 |
|---|---|
| ArrangementView | 决定 A/B 的分配区域、排列和显示行为 |
| 面板内部视图 | 在分配到的区域中绘制风景、滚动队列、显示控制条 |
| 实验说明与诊断 | 展示尺寸、活动分隔区域和当前模式的观察目标 |
这意味着,本文没有用“宽度超过某个数就创建 HStack”的方式冒充 ArrangementView,也没有用按钮模拟折叠后再手动摆放两个面板。三种模式切换的是 arrangement 策略;外屏、内屏和折叠姿态则由真实运行环境提供。
如果业务需要自行测量和放置任意数量的子视图,仍可以实现Layout。它提供的是更底层的几何布局能力。Layout 文档
二、先认识三个演示,避免“切了模式却不知道在看什么”
新版界面用风景画面与队列形成明显对照。风景由 SwiftUI 的渐变和 Path 绘制,没有图片资源、网络服务或第三方依赖。播放按钮只改变 UI 状态,不播放真实音频。
| 页面选项 | 使用的样式 | 重点观察 |
|---|---|---|
| 双面板 | .split | 蓝色 A 与橙色 B 如何上下、左右重排 |
| 主辅切换 | .split.axes(.horizontal) | 不允许上下分割时,B 是否退让,A 是否获得更多空间 |
| 浮动控制台 | .overlay | B 是叠在 A 上的控制条,还是获得独立区域的完整控制台 |
页面顶部始终保留队列入口。即使 B 暂时不可见,也能在弹窗中选择内容。playing和selectedTrack保存在 arrangement 外部,模式切换不会主动创建一套新的播放状态。
这里还要区分业务身份和API 角色:
| 模式 | primary | secondary |
|---|---|---|
| 双面板、主辅切换 | A:风景播放器 | B:播放队列 |
| 浮动控制台 | B:前景控制台 | A:背景风景 |
A/B 标识用于帮助读者追踪同一块内容,并不固定等同于 primary/secondary。尤其在 overlay 中,前景控制台才是 primary。overlay 文档
三、双面板:看系统如何重新分配空间
当前工程中的核心代码如下,ScenicPane和TrackPane的完整实现见文末:
ArrangementView{ScenicPane(mode:mode,selectedTrack:selectedTrack,playing:$playing).measurePane("A")}secondary:{TrackPane(selectedTrack:$selectedTrack).measurePane("B")}.arrangementViewStyle(.split)在官方视频的演示里,容器偏宽时左右分割,偏高时上下分割;实际决策还受到环境影响。视频:12:00 起
本次外屏实测中,A 和 B 上下排列,各自获得382 × 211 pt。这次显示的是每块面板自己的 GeometryReader 尺寸,而不是包含标题、说明文字在内的整个页面高度。
到了全展开内屏,重点应是“每块内容现在获得了多少空间、排列是否变化”。不能预设内屏一定左右排列:旋转、分屏、系统栏以及页面自己的说明区域都会改变 arrangement 的可用比例。
半展开时,还要观察面板边界与活动 division 的关系。不要仅凭截图更宽,就判断折叠适配已经发生。比较时应同时记录运行姿态、活动区域及面板尺寸。
容器自适应,不代表面板内容可以固定尺寸
新版风景按面板宽高绘制,队列则在 B 内部滚动。因此,当 A 被压缩成横向短面板、B 的高度减少时,内容仍能利用自己的区域。ArrangementView 本身位于导航容器内部,而不是被包进滚动列表;滚动发生在面板内部。视频:容器使用边界
如果内容有明确约束,可以在相应面板上设置splitArrangementLayoutSize或splitArrangementLayoutRatio。本例不添加比例约束,以便先观察系统的默认分配。比例偏好还会受到优先级和剩余空间影响,不能理解成每种姿态下都严格成立的固定比例。比例 API 文档
四、主辅切换:限制方向,观察 B 如何退让
第二个模式只改变允许的分割方向:
ArrangementView{ScenicPane(mode:mode,selectedTrack:selectedTrack,playing:$playing).measurePane("A")}secondary:{TrackPane(selectedTrack:$selectedTrack).measurePane("B")}.arrangementViewStyle(.split.axes(.horizontal))它表达的是“允许水平分割”,并不意味着空间不足时仍必须挤出两列。官方视频中,当主方向为纵向而样式只允许水平分割时,只显示主内容。视频:12:41
本次外屏结果非常直观:橙色 B 不再显示,蓝色 A 从382 × 211 pt扩大到382 × 422 pt。风景在同一窗口里获得了双倍高度,读者可以直接看到“限制方向”的结果。
接着应在全展开内屏和旋转后检查 B 是否重新出现。这里的测试对象是系统分配结果,不是自行维护一个isDuoExpanded布尔值来隐藏队列。
B 的退让也解释了为什么保留独立队列入口很重要:布局可以收起一块面板,但不应同时删除用户选择内容的能力。
五、浮动控制台:从叠放到独立区域
第三个模式把橙色控制台设为 primary,把蓝色风景设为 secondary:
ArrangementView{AdaptiveConsole(compact:panesOverlap,selectedTrack:$selectedTrack,playing:$playing).measurePane("B").overlayArrangementEdge(HorizontalEdge.trailing).overlayArrangementEdge(VerticalEdge.bottom)}secondary:{ScenicPane(mode:mode,selectedTrack:selectedTrack,playing:$playing).measurePane("A")}.arrangementViewStyle(.overlay)overlay 可以先让内容叠放,再在环境变化时将内容分开排列。边缘修饰符指定转为相应方向布局时的位置偏好;这里明确区分水平 trailing 和垂直 bottom 两个重载。ArrangementView 文档 · 边缘修饰符
在本次外屏截图中,A 占据背景,B 是带橙色边框的浮动控制条。它与“主辅切换”不同:B 没有消失,而是与 A 使用重叠的空间。
当系统将两个分配区域分开时,新版代码会把 B 展开为独立控制台:显示实际尺寸、完整可滚动队列、播放按钮和下一段按钮。这个分离分支已经实现并通过编译,尚未取得半展开内屏的运行截图,不能写成已经实测成功。
官方视频使用环境值;本例观测实际几何
视频通过overlayArrangementZIndex调整内容的紧凑状态。这个环境值描述 arrangement 中的叠放层级,不是设备折叠角度。环境值文档
早期实验里,不同读取层级曾出现不同读数;用户后续截图中的splitArrangementAxis也与先前运行观察不同。这些观察不足以证明 API 存在通用缺陷,更不能建立“某个 axis 值就代表两个面板都可见”的规则。新版因此不再把 axis/z-index 直接作为页面上的布局结论。
本例采用另一种可检查的方法:在同一命名坐标空间中测量 A/B 的分配矩形,判断两者是否相交。这个判断只改变控制台内部内容,不负责把 A/B 摆到哪里。
privatestructPaneFrames:PreferenceKey{staticvardefaultValue:[String:CGRect]{[:]}staticfuncreduce(value:inout[String:CGRect],nextValue:()->[String:CGRect]){value.merge(nextValue(),uniquingKeysWith:{_,newinnew})}}privateextensionView{funcmeasurePane(_name:String)->someView{background{GeometryReader{proxyinColor.clear.preference(key:PaneFrames.self,value:[name:proxy.frame(in:.named("arrangementStage"))])}}}}在 arrangement 外部建立坐标空间并接收观测结果:
privatevarstage:someView{arrangement.coordinateSpace(name:"arrangementStage").onPreferenceChange(PaneFrames.self){frames=$0}.clipped()}然后计算重叠状态:
privatevarpanesOverlap:Bool{guardleta=frames["A"],letb=frames["B"],a.width>1,b.width>1else{returntrue}letintersection=a.intersection(b)return!intersection.isNull&&intersection.width>2&&intersection.height>2}首次布局尚未得到有效矩形时,默认采用紧凑控制条;两个矩形在两个方向上都有超过 2 pt 的交集时,视为重叠。2 pt 是这个演示的几何容差,不是 Apple 规定的折叠阈值。
控制台外层始终填满系统分配的区域,内部才在紧凑条与完整队列之间切换。这样可以减少“内容变大导致测量变大、测量变大又改变内容”的反馈。播放状态与选中项目保持在父视图中,不因内容分支切换而主动重置。
这个办法也有明确边界:矩形交集不是通用可见性检测,无法判断 opacity、复杂裁剪或遮挡顺序;过渡动画中还可能出现短暂相交。本例只用它区分两个 overlay 面板的分配关系,不用它推断设备姿态,也不用它判断主辅模式中 B 是否可见。推广到生产界面前,应验证动画、旋转和折叠全过程。
六、外屏、全展开、半展开:哪些信息能判断,哪些不能
新版页面会查询当前视图范围内的 division:
letregions=proxy.reservedRegions(kind:.division,options:.includeInactive,layoutDirectionBehavior:.fixed)letactive=regions.filter(\.isActive).includeInactive让示例显式纳入非活动区域,再通过isActive分类,避免依赖资料中存在差异的默认查询描述。includeInactive 文档
页面只给出与查询证据相符的说明:
| 查询结果 | 页面显示 | 不能据此推断的内容 |
|---|---|---|
| 存在活动 division | 活动折叠区域,观察两侧避让 | 具体折叠角度和所有控件是否已避让 |
| 存在 division,但未激活 | 折叠区域未激活,观察连续画布 | 所有设备和窗口条件下都代表全展开 |
| 没有返回 division | 当前窗口没有折叠分隔区域 | 一定是外屏、一定是普通 iPhone |
因此,“外屏”“全展开内屏”“半展开内屏”是测试者在 Simulator 中设置并记录的场景,不是这段区域查询代码完整识别出的三种设备状态。
粉色标记只画出系统返回的区域
点击状态行右侧的标记按钮,可以开关活动 division 的粉色覆盖层。区域在实验舞台自己的 GeometryReader 中重新查询,避免把外层容器坐标直接拿来画在内部视图上。
这里使用.fixed,按照返回的固定几何坐标绘制调试层;真正的内容布局仍由 ArrangementView 处理。粉色层禁用命中测试,不拦截按钮,也不会通过额外 padding 再避让一次铰链。需要自行实现 RTL 布局时,应仔细核对 API 的镜像语义。reservedRegions 文档
.division与.occlusion也不能混为一谈:前者表示分隔,后者表示遮挡。当前粉色层只显示 division,不是摄像头轮廓,也不是所有安全区域的可视化。
七、摄像头遮挡修复:把入口放在可控的内容区域
此前的实际截图中,右上角工具栏的队列按钮与摄像头重叠。把工具栏项改成.topBarLeading后,系统仍可能根据 Duo 的布局规则将栏内容重新排列,不能只凭 placement 名称判断物理位置。
新版把队列入口放到模式选择器左侧,位于页面内容内部;弹窗的“完成”按钮则放在底部安全区域:
.safeAreaInset(edge:.bottom){Button("完成"){dismiss()}.buttonStyle(.borderedProminent).frame(maxWidth:.infinity,minHeight:44).padding(8).background(.bar)}这是一项针对演示界面的布局修正,不是自行探测摄像头后计算位移的通用方案。当前外屏截图已检查入口位置,但所有内屏姿态、RTL 和大字号仍需继续验证。不要把它扩展成“工具栏都不应该使用”的结论。
Apple 的设计指导强调可调整尺寸、边距与安全区域,以及不同姿态下的功能连续性;本文保留独立队列入口也是出于这个目的。Design for iPhone Duo
八、验证记录与接下来的截图矩阵
当前ArrangementViewTest已用 Xcode 27.1 编译成功,并在 Duo / iOS 27.1(24A94401)运行。构建日志保存在配套目录的evidence/current-project-build.log中。现有 AppIntents 警告表示未依赖相应框架而跳过元数据提取,与本例布局功能无关。
| 场景 | 本次状态 | 证据或待观察内容 |
|---|---|---|
| 外屏,双面板 | 已运行并截图 | A/B 上下排列,各 382 × 211 pt |
| 外屏,主辅切换 | 已运行并截图 | 仅显示 A,382 × 422 pt |
| 外屏,浮动控制台 | 已运行并截图 | A 为 382 × 422 pt,B 是重叠控制条 |
| 全展开内屏,三种模式 | 待验证 | A/B 尺寸与排列;覆盖模式是否仍叠放 |
| 半展开内屏,三种模式 | 待验证 | division 活动标记、面板位置、控制台分离分支 |
| 内屏旋转、分屏、外内屏切换 | 待验证 | 页面重排与播放状态连续性 |
| 普通 iPhone / iOS 27.1 | 未验证 | 当前安装的 runtime 不支持创建相应对照设备 |
面板尺寸是这次截图的结果,不是实现中的断点,也不是对所有 Duo 的固定规格。两种姿态得到相同布局是可能的;示例不能为了“看起来有变化”而人为强制排列。
推荐的实际操作顺序
- 在外屏依次选择三种模式,对照本文截图,确认 A/B 的含义和变化。
- 将设备切到全展开内屏,保持同一模式和内容,记录面板尺寸、division 和布局。
- 让内屏部分折叠,重点查看粉色区域与面板边界;在覆盖模式中检查 B 是否从浮动条变成独立控制台。
- 旋转设备后重复,确认没有把“横屏”和“水平分割”混为一谈。
- 播放/暂停、选择另一段内容,再切换模式与姿态,检查业务状态是否保留。
- 最后测试大字号和弹窗,避免只验证静态画面。
不要通过调整 SwiftUI frame 或直接写入环境值冒充真实折叠测试。需要在 Simulator 的姿态控制中改变设备状态,再记录真实结果。
截图前先确认显示端口和目标设备:
/Applications/Xcode27.1.app/Contents/Developer/usr/bin/simctl io booted enumerate /Applications/Xcode27.1.app/Contents/Developer/usr/bin/simctl io booted screenshot--display=1screenshot.png1是本次外屏截图所用编号,内屏应以枚举结果为准。同时启动多台模拟器时,请将booted换成目标 UUID。截图说明应包含模式、外/内屏、姿态、运行时版本和区域信息。
九、工程结构与官方阅读路线
当前项目保留原有 App 入口:
ArrangementViewTestApp.swift:应用入口。ContentView.swift:系统可用性检查与预览。ArrangementLab.swift:三个演示、面板几何观测、区域标记和说明弹窗。
配套压缩包中的独立ArrangementLab.xcodeproj也已同步同一套实现,便于读者单独打开;其Sources/ArrangementLabApp.swift是上述三份文件的合并版本。两个工程择一运行即可。本文截图来自当前ArrangementViewTest工程。
建议在官方主视频中依次查看 6:39 区域查询、11:17 创建容器、13:21 覆盖布局,并与本文的外屏截图和内屏待验证矩阵对照。
API 详情可以继续查阅 SwiftUI 更新、样式修饰符、splitArrangementAxis 和 Xcode 27.1 发布说明。实现中的几何观测属于本文的演示逻辑,应与系统 API 的保证区分开。
那么,感谢宝子们的观赏,我们下次不见不散哦!😎