5 步让 UIKit App 适配 iPhone Duo 的每一种窗口
FDuoLayout 只读系统上报的信息(窗口尺寸、安全区、竖条边、保留区域、铰链),从不判断机型,所以普通 iPhone 的布局保持不变。f-duo-layout skill 负责找出会出问题的代码,并用这套 API 修复。本页是 中文 README 的补充;English。
5 种窗口,一套代码
Fig. 1 同一个 App 会遇到 5 种窗口;左分屏时侧轨移到左边,而且里面没有状态栏。
按 iPhone Duo 模拟器实测尺寸(pt)等比绘制。普通 iPhone 没有侧轨,下面所有 API 在它上面返回的值与接入前相同。
5 步接入
Fig. 2 接入分 5 步,每步都有对应的工具。
共 5 步,点「下步」或按 → 开始。
通过 Swift Package Manager,从 GitHub 或本地 clone 引入。App 最低版本需为 iOS 27.1。
一条命令装给编码 agent。从 clone 安装时脚本建软链,git pull 即更新。
扫描只找出候选行,再按 rules.md 逐条判定:缺陷、无问题或需运行时确认。
按症状选 API(见下一节)。代码对所有 iPhone 都成立,不写机型判断。
在模拟器上走完每种姿态,用日志行断言。普通 iPhone 必须保持不变。
添加包并安装 skill:
// Package.swift
dependencies: [
.package(url: "https://github.com/jeffy-w/FDuoLayout.git", branch: "main"),
],
targets: [
.target(name: "App", dependencies: ["FDuoLayout"]),
]
npx skills add jeffy-w/FDuoLayout --skill f-duo-layout # 任意 agent;加 -g 装到用户级
Scripts/install-skill.sh # 从 clone:~/.claude/skills 与 ~/.agents/skills
Scripts/install-skill.sh --project <your app> # 从 clone:只装一个项目
按症状选 API
每条用法都是 skills/f-duo-layout/references/patterns.md 中一个模式的简写。更新方法要在布局回调里调用:折叠只改变窗口尺寸,不一定触发其他回调。
只取一次的尺寸在折叠后过期LayoutWidthClass
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Never UIScreen.main.bounds or a static let: read the container on every layout pass.
let widthClass = LayoutWidthClass(width: view.bounds.width)
guard widthClass != appliedWidthClass else { return }
appliedWidthClass = widthClass
updateGrid(columns: widthClass.columnCount(compact: 3)) // 3 / 4 / 6
}分级:compact ≤ 500 < medium ≤ 800 < expanded。单栏文字宽度上限用 LayoutWidthClass.maxSingleColumnContentWidth(466 pt)。对应模式 1–3。
内容被侧轨压住SideRailAvoidingLayoutGuide
private let railGuide = SideRailAvoidingLayoutGuide()
override func viewDidLoad() {
super.viewDidLoad()
railGuide.install(in: view)
NSLayoutConstraint.activate([
// Pin both edges: the rail is on the left in a left split view.
tableView.leftAnchor.constraint(equalTo: railGuide.leftAnchor),
tableView.rightAnchor.constraint(equalTo: railGuide.rightAnchor),
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
railGuide.update()
}按视图静止位置计算,push 或返回手势时缩进不会跳动。碰不到侧轨的居中卡片得到 0。对应模式 4。
侧轨里的按钮压住状态栏SideRail.statusBarClearance(in:)
guard let rail = view.windowSideRail, let window = view.window else { return }
NSLayoutConstraint.deactivate(railConstraints)
railConstraints = SideRail.stripConstraints(for: railView, host: view, side: rail.side)
NSLayoutConstraint.activate(railConstraints)
let clearance = SideRail.statusBarClearance(in: view) // window coordinates
let top = railView.convert(CGPoint(x: 0, y: clearance.firstUsableY), from: window).y
railButtonTop.constant = top + 16折叠时放在 156 pt 状态栏下方;左分屏为 .railWithoutStatusBar,对齐 y 34。对应模式 5。
顶部一行撞上状态栏或摄像头OcclusionAvoidingLayoutGuide
private let leftGuide = OcclusionAvoidingLayoutGuide(side: .left)
private let rightGuide = OcclusionAvoidingLayoutGuide(side: .right)
// in setup
leftGuide.install(in: headerView)
rightGuide.install(in: headerView)
backButton.leftAnchor.constraint(greaterThanOrEqualTo: leftGuide.leftAnchor, constant: 16).isActive = true
actionButton.rightAnchor.constraint(equalTo: rightGuide.rightAnchor, constant: -16).isActive = true
// in layoutSubviews, after the row has its frame
if leftGuide.update(row: titleRow.frame) || rightGuide.update(row: titleRow.frame) {
setNeedsLayout()
}只有与状态栏重叠的行会让开,下面的行保持全宽。对应模式 6。
底部面板长到状态栏下面SheetLayout
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
sheetHeight.constant = min(contentHeight, SheetLayout.maxHeight(in: view))
}无论状态栏在哪,面板顶边都留在图标下方 12 pt;放不下的内容在面板内滚动。对应模式 7。
全屏弹出页被左侧轨压住syncLeftSideRailSafeArea()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
syncLeftSideRailSafeArea()
}只把 additionalSafeAreaInsets.left 补到缺少的量,侧轨离开时再去掉。对应模式 8。
内容要沿铰链分成两栏HingeDivisionObserver
private let hinge = HingeDivisionObserver()
override func viewDidLoad() {
super.viewDidLoad()
hinge.onChange = { [weak self] _ in self?.view.setNeedsLayout() }
hinge.attach(to: view)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
hinge.evaluate() // size changes do not always produce a hinge update
if let division = hinge.division, division.isVertical {
leadingWidth.constant = division.leadingColumnMaxX
trailingLeft.constant = division.trailingColumnMinX
} else {
// single-column layout
}
}≤ 140° 进入,> 150° 退出,进入前等铰链稳定 0.6 s。不要自己比较 UIHinge.Status。对应模式 10。
帐篷姿态下内容要正立TentPostureObserver
private let tent = TentPostureObserver()
override func viewDidLoad() {
super.viewDidLoad()
tent.onChange = { [weak self] state in
guard state.hasHingeReading else { return } // unknown is not "left the tent"
state.isTent ? self?.showTentContent() : self?.hideTentContent()
}
tent.attach(to: view)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
tent.evaluate()
}帐篷页锁定到 TentOrientation.interfaceOrientationMask(for:) 给出的方向。打开竖屏方向锁时检测不到帐篷。对应模式 11。
没有 view 的代码需要窗口WindowLookup
let window = someView.window ?? WindowLookup.primaryWindow()
let scene = someView.window?.windowScene ?? WindowLookup.activeWindowScene()优先用 view.window。查找函数会跳过辅助 key window,但多 scene 时只能猜。对应模式 12。
让 agent 审计并修复
| 指令 | 效果 |
|---|---|
/f-duo-layout global | 全局普查:按目录统计命中、排出优先修复的目录、列出有命中但没有适配标记的文件 |
/f-duo-layout SettingsViewController | 页面检查:类型本身、扩展、它持有的视图和宿主;逐条判定命中;检查结构规则 S-01…S-14、H-01…H-05、T-01…T-05 |
| …并修复 | 只修已确认的缺陷,用规定的模式;修完重扫,不能新增 P0 / P1 |
| …并验证 | 按模拟器流程跑一遍,报告里写明哪些跑了、哪些没跑 |
扫描脚本也可以脱离 agent 单独运行,比如放进 CI:
A=skills/f-duo-layout/scripts/duo_layout_audit.py
python3 -B -I $A <sources> --detail # 列出每个命中的 file:line
python3 -B -I $A --min-level P1 --detail <sources> # 只看 P0 / P1
python3 -B -I $A --size-symbol screenWidth <sources> # 项目里保存屏幕尺寸的全局变量
| 退出码 | 含义 |
|---|---|
| 0 | 豁免后没有 P0 / P1(P2 与 ADV 默认不算失败,除非 --fail-on P2) |
| 1 | 至少有一个命中达到 --fail-on 级别(默认 P1) |
| 2 | 路径不存在或没有 Swift 文件;这不等于干净 |
有意保留的命中,在它上一行加豁免注释: // duo-layout-audit: ignore DL-xx <reason>.
在模拟器上验证,用日志行判定
所有命令用同一个 iPhone Duo 模拟器 UDID,用 hinge skill 转动铰链(npx skills add artemnovichkov/hinge -g)。被测页面每次布局变化打印一行固定格式的日志,再对它做断言。
HINGE="$HOME/.agents/skills/hinge/scripts/hinge"
"$HINGE" -d "$UDID" close # folded, 466 x 678
"$HINGE" -d "$UDID" open # unfolded
"$HINGE" -d "$UDID" orient portrait # unfolded -> landscape window 951 x 669
"$HINGE" -d "$UDID" half # 90 degrees, half fold
xcrun simctl spawn "$UDID" log show --last 1m --style compact \
--predicate 'subsystem == "<your subsystem>"' | grep -E 'window=466x678 .* rail=right/84/1'
| 场景 | 预期 |
|---|---|
| 折叠 | 466 × 678,右侧轨 84,侧轨第一个控件在 y 156 以下 |
| 展开,两个方向 | 951 × 669 带右侧轨;669 × 951 无侧轨,状态栏在顶部 |
| 左右分屏 | 都是 469 × 669;左侧 App 的侧轨在左且无状态栏,右侧 App 的侧轨在右且有状态栏 |
| 半折叠 | 稳定等待后变两栏;一展开立即回到单栏 |
| 帐篷 | 出现帐篷内容且保持正立;离开帐篷姿态时消失 |
| 普通 iPhone | 所有数值与改动前一致 |
完整流程、分屏摆放方法和检查清单见 skills/f-duo-layout/references/verify.md。Demo/ 里的示例 App 演示了每条用法,并按这种格式打印 FDL … 日志。
限制
| 限制 | 影响 |
|---|---|
| iOS 27.1+ | 包直接使用 UIHinge、UIHingeInteraction、reservedRegions(kind:) 和 verticalBarEdge。用更早 SDK 编译的 App 只会运行在固定 375 × 667 的兼容窗口里 |
| 仅 UIKit | 没有 SwiftUI API。审计脚本仍会扫描 SwiftUI 文件;承载 SwiftUI 页面的 UIKit 宿主可以使用这些 guide 和 observer |
| 实测阈值 | 角度、延迟和基线来自模拟器;帐篷方向映射已在真机确认。都可以配置:HingeDivisionConfiguration、TentPostureConfiguration、SideRail.MeasuredGeometry |