Fit a UIKit app to every iPhone Duo window in five steps
FDuoLayout reads what the system reports (window size, safe areas, the vertical bar edge, reserved regions, the hinge), never the device model, so regular iPhones keep their layout. The f-duo-layout skill finds the code that breaks and fixes it with this API. This page supplements the README. 中文版.
Five window shapes, one code path
Fig. 1 One app sees five window shapes; the rail moves to the left in a left split view and carries no status bar there.
Drawn to scale from the iPhone Duo simulator (points). A regular iPhone has none of these rails, so every API below returns the values it had before.
Adopt it in five steps
Fig. 2 Adoption takes five steps, and each step has its own tool.
5 steps. Press Next or → to start.
Swift Package Manager, from GitHub or a local clone. The app must target iOS 27.1 or later.
One command for your coding agent. From a clone, the script links it so git pull updates it.
The scan finds candidate lines. Each one is judged with rules.md: defect, no issue, or runtime check.
Pick the API by symptom (next section). The code is correct on every iPhone; no model checks.
Walk every posture on the simulator and assert on log lines. A regular iPhone must stay unchanged.
Add the package and install the 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 # any agent; add -g for user level
Scripts/install-skill.sh # from a clone: ~/.claude/skills and ~/.agents/skills
Scripts/install-skill.sh --project <your app> # from a clone: one project only
Pick the API by symptom
Each recipe is the short form of a pattern in skills/f-duo-layout/references/patterns.md. Call the update methods from the layout pass: a fold changes the window size without other callbacks.
A size taken once is stale after foldingLayoutWidthClass
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
}Buckets: compact ≤ 500 < medium ≤ 800 < expanded. Cap single-column text at LayoutWidthClass.maxSingleColumnContentWidth (466 pt). Pattern 1–3.
Content runs under the side railSideRailAvoidingLayoutGuide
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()
}Uses the resting position, so a push or a back swipe does not make the inset jump. A centered card that never reaches the rail gets 0. Pattern 4.
Buttons in the rail overlap the status barSideRail.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 + 16Folded: below the 156 pt status bar. Left split view: .railWithoutStatusBar, align to y 34. Pattern 5.
A top row collides with the status bar or cameraOcclusionAvoidingLayoutGuide
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()
}Only rows that overlap the status bar move; rows below it keep the full width. Pattern 6.
A bottom sheet grows under the status barSheetLayout
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
sheetHeight.constant = min(contentHeight, SheetLayout.maxHeight(in: view))
}Keeps the sheet 12 pt below the status bar icons wherever they are. Content that does not fit scrolls. Pattern 7.
A full-screen presentation runs under the left railsyncLeftSideRailSafeArea()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
syncLeftSideRailSafeArea()
}Tops up additionalSafeAreaInsets.left only by the missing amount and removes it when the rail leaves. Pattern 8.
Content should split around the hingeHingeDivisionObserver
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
}
}Enters at 140° or less, leaves above 150°, waits 0.6 s for the hinge to settle. Do not compare UIHinge.Status yourself. Pattern 10.
Content should stand upright in tent postureTentPostureObserver
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()
}The tent page locks to TentOrientation.interfaceOrientationMask(for:). The portrait orientation lock hides the tent. Pattern 11.
Code without a view needs a windowWindowLookup
let window = someView.window ?? WindowLookup.primaryWindow()
let scene = someView.window?.windowScene ?? WindowLookup.activeWindowScene()Prefer view.window. The lookups skip auxiliary key windows but guess with several scenes. Pattern 12.
Let the agent audit and fix
| Ask for | What happens |
|---|---|
/f-duo-layout global | Survey: hits per directory, the directories to fix first, files with hits but no adaptation markers |
/f-duo-layout SettingsViewController | Page check: the type, its extensions, its owned views and its host; every hit judged; structural rules S-01…S-14, H-01…H-05, T-01…T-05 |
| … and fix it | Confirmed defects only, with the patterns; rescan; no new P0 / P1 |
| … and verify it | Runs the simulator workflow and reports what ran and what did not |
The scan also runs without an agent, for example in CI:
A=skills/f-duo-layout/scripts/duo_layout_audit.py
python3 -B -I $A <sources> --detail # every hit with file:line
python3 -B -I $A --min-level P1 --detail <sources> # P0 / P1 only
python3 -B -I $A --size-symbol screenWidth <sources> # project globals that hold a screen size
| Exit code | Meaning |
|---|---|
| 0 | No P0 / P1 hit after waivers (P2 and ADV never fail unless --fail-on P2) |
| 1 | At least one hit at or above --fail-on (default P1) |
| 2 | A path does not exist or there is no Swift file: not a clean result |
An intentional hit gets a waiver on the line above it: // duo-layout-audit: ignore DL-xx <reason>.
Verify on the simulator, judge by log lines
Use one iPhone Duo simulator UDID for every command and move the hinge with the hinge skill (npx skills add artemnovichkov/hinge -g). Print one fixed-format line per layout change from the page under test, then assert on it.
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'
| Scenario | Expect |
|---|---|
| Fold | 466 × 678, rail right 84, first rail control below y 156 |
| Unfold, both orientations | 951 × 669 with a right rail; 669 × 951 with no rail and the status bar on top |
| Split view left and right | Both 469 × 669; rail left without status bar on the left, rail right with status bar on the right |
| Half fold | Two columns after the settle wait; one column again as soon as the device opens |
| Tent | Tent content appears and stays upright; it leaves when the device leaves the tent |
| Regular iPhone | Every value equals what it was before the change |
The full workflow, split view arrangement and checklist are in skills/f-duo-layout/references/verify.md. The demo app in Demo/ shows each recipe and prints FDL … lines in this format.
Limits
| Limit | Consequence |
|---|---|
| iOS 27.1+ | The package uses UIHinge, UIHingeInteraction, reservedRegions(kind:) and verticalBarEdge directly. Apps built with an earlier SDK run in a fixed 375 × 667 compatibility window |
| UIKit only | No SwiftUI API. The audit still scans SwiftUI files, and the UIKit host of a SwiftUI screen can use the guides and observers |
| Measured thresholds | Angles, delays and baselines come from the simulator; the tent orientation mapping is confirmed on hardware. All are configurable: HingeDivisionConfiguration, TentPostureConfiguration, SideRail.MeasuredGeometry |