FDuoLayout · 使用说明

5 步让 UIKit App 适配 iPhone Duo 的每一种窗口

FDuoLayout 只读系统上报的信息(窗口尺寸、安全区、竖条边、保留区域、铰链),从不判断机型,所以普通 iPhone 的布局保持不变。f-duo-layout skill 负责找出会出问题的代码,并用这套 API 修复。本页是 中文 README 的补充;English。

5 种窗口,一套代码

Fig. 1 同一个 App 会遇到 5 种窗口;左分屏时侧轨移到左边,而且里面没有状态栏。

侧轨 84 pt侧轨内的状态栏顶部状态栏 82 pt内容区466 × 678折叠外屏 · compact669 × 951展开竖屏内屏 · medium951 × 669展开横屏内屏 · expanded469 × 669右分屏App 在右侧 · compact469 × 669左分屏App 在左侧 · compact

按 iPhone Duo 模拟器实测尺寸(pt)等比绘制。普通 iPhone 没有侧轨,下面所有 API 在它上面返回的值与接入前相同。

5 步接入

Fig. 2 接入分 5 步,每步都有对应的工具。

1 添加Package.swift.package(url:) or (path:)import FDuoLayoutiOS 27.1+ · UIKit2 装 skillnpx skills addjeffy-w/FDuoLayoutinstall-skill.sh从 clone 建软链3 审计duo_layout_audit.py扫描 DL-01…DL-17exit 0 / 1 / 2干净 · 有 P0/P1 · 路径无效rules.md逐条判定命中4 修复SideRailAvoidingLayoutGuide内容让开侧轨OcclusionAvoidingLayoutGuide只移动被遮挡的行HingeDivisionObserver半折叠两栏TentPostureObserver帐篷内容5 验证iPhone Duo simulator折叠 · 展开 · 分屏日志断言固定格式的行普通 iPhone数值不变

共 5 步,点「下步」或按 → 开始。

  1. 通过 Swift Package Manager,从 GitHub 或本地 clone 引入。App 最低版本需为 iOS 27.1。

  2. 一条命令装给编码 agent。从 clone 安装时脚本建软链,git pull 即更新。

  3. 扫描只找出候选行,再按 rules.md 逐条判定:缺陷、无问题或需运行时确认。

  4. 按症状选 API(见下一节)。代码对所有 iPhone 都成立,不写机型判断。

  5. 在模拟器上走完每种姿态,用日志行断言。普通 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