Adapting for iPhone Duo: Geometry-Driven Layout with System APIs
iPhone Duo Adaptation Practice: From Geometry Principles to Engineering Wrappers
Based on our company project's adaptation experience, for reference only; adjust to your own project.
iPhone Duo is a foldable iPhone with three common postures:
- Outer screen: the narrow screen used when closed, width is compact, behaves like a regular straight phone.
- Inner screen: the large screen when unfolded. Held horizontally, the hinge vertically splits the screen; held vertically, the hinge runs horizontally across the screen. In both orientations of the inner screen, the horizontal size class is regular, and left/right split-screen makes each half compact.
- Half-fold: the inner screen is folded at an angle; the hinge goes from inactive to active, and content crossing the hinge is split by the crease.
Another new thing is the system vertical bar: on the outer screen and inner screen held horizontally, system elements (status bar, Wi-Fi, camera) move into a vertical bar on one side of the screen instead of across the top. The bar is 84pt wide (measured on simulator, may change with system version). When the bar appears, the system only manages its own UINavigationController navigation bar; an app-drawn navigation bar and TabBar are not moved into it, so you must handle those yourself.
This article records the principles we set during Duo adaptation, the underlying geometry, how modules are split, pitfalls encountered, and unfinished parts. All data comes from the iOS 27.1 iPhone Duo simulator; there is no real-device data yet.
1. Design Principles
1.1 Don't Check Device Model
Layout decisions never use userInterfaceIdiom, UIInterfaceOrientation, or a check for "is Duo." All decisions go through a single outlet, DuoEnvironment, which reads traits (size class, which side the vertical bar is on) and system-provided geometry regions.
DuoEnvironment has a Posture enum (outer screen / inner portrait / inner landscape, etc.), but it is only for logging, debugging, and acceptance, never for layout. The reason is split-screen: after the inner screen is split left/right, each window gets compact, so laying out as "inner screen is big" would be wrong. Layout only uses size class and actual available width, so split-screen, outer screen, and straight phones naturally follow the same path.
1.2 Compact Is the Baseline
Straight phones, the Duo outer screen, and narrow split-screen windows all have a horizontal size class of compact; these cases must keep the existing layout, pixel for pixel. This is guaranteed by algorithm structure rather than page-by-page checks: the first step of the column-count function is "not regular width, return baseline column count," so the rest of the calculation never runs. Adaptation code only runs in the regular branch.
1.3 Geometry Values Only from System APIs
Geometry values are taken only from public system APIs:
safeAreaLayoutGuide: the vertical bar is already included in the safe area.layoutGuide(for: .bar(onEdge:extent:)): the layout region for the system vertical bar / top bar; already clears the status bar and camera.reservedRegions(kind:options:): the hinge (.division) and camera occlusion (.occlusion).NSCollectionLayoutEnvironment.container.effectiveContentSize: the list's effective content width.
Of these, traitCollection.verticalBarEdge, reservedRegions(kind:options:), and layoutGuide(for: .bar(onEdge:extent:)) are available since iOS 27.1; on lower versions, they go down the "no bar, no hinge" branch.
We do not write measured constants like "bar top offset" or "hinge x position." But two exceptions are acknowledged:
- Status bar icon position. On inner portrait, the design requires pixel-level alignment between the custom top row and the Wi-Fi icon center, and there is no public API for status bar icon position. We used two measured constants: Wi-Fi center is 34pt above the safe area top, and the right-side time / Wi-Fi / battery cluster is 120pt wide (both simulator-measured, may change with system version). The 120pt is riskier: cellular signal, battery percentage, and Live Activities can all widen the cluster. Re-verify on system upgrades or the first real-device test.
- Two vertical TabBar constants. The vertical TabBar top is no higher than 96pt (
verticalMinTop), and the pill center is 48pt from the screen edge (verticalBarCenterInset); both are simulator-measured and may change. We had to measure because under the vertical bar,statusBarFramereports only 2pt high (simulator-measured), which is unusable. This is existing tech debt, planned to migrate to the bar layout region.
1.4 Grid Defaults to Even Columns, Exception When No Vertical Hinge
Grid column counts are computed only by ColumnPolicy, which defaults to even numbers. HIG requires even columns so that in half-fold, content splits exactly along the hinge and no card is cut by the crease.
The exception: grids (waterfall, bookshelf grid, search ranking, etc.) may use odd columns when there is no vertical hinge. The typical case is inner portrait: the hinge crosses the screen horizontally, so it cuts between rows, and odd/even columns make no difference. Then 3.6 columns by width becomes 3, rather than forcefully dropping to 2.
The criterion only reads reservedRegions(kind: .division, options: .includeInactive). The .includeInactive option is used so full-open (hinge inactive) and half-fold (active) compute the same column count, without reflowing when you fold. List classes (single/double-column result lists) are not affected by this exception; inner portrait remains 1 column.
Column spacing uses the page's existing constants, unchanged when column count changes.
1.5 Full-Open Does Not Avoid the Hinge; Half-Fold Avoidance Is Pending
When full-open, the hinge is inactive and the screen is one continuous display area; content does not avoid the hinge, and 4 columns are evenly divided across the full width.
When half-fold, the hinge is active. Custom overlays (confirm dialogs, Toasts, etc.) centered on the screen will ride on the hinge. This part is not finished: currently only a few dialogs get width whitespace; a unified "avoid to half screen" outlet is not implemented, and the simulator cannot control half-fold posture, so actual behavior is unverified.
1.6 No Feature Flags
Projects typically put new features behind flags, default off. Duo adaptation deliberately does not: the compact branch is pixel-unchanged (see 1.2), and the regular branch itself is fixing layout issues. Adding a flag would default to a known-broken layout, which is pointless.
1.7 No Test Target; selfCheck Instead
The project has no unit test target. Geometry judgments and column-count algorithms are written as pure functions, each carrying a selfCheck() that compiles only in development builds, verifying outputs against fixed inputs. For example, at design base width 375, the grid minimum column width should be 163.5 and the list should be 343.
DuoSelfCheck.run() aggregates all module self-checks and is manually triggered from the debug menu. It deliberately avoids assert: if an assertion fails immediately, you only see the first error; aggregating and listing all failures is better for debugging. When adding a new pure-function module, add one line to run().
2. Design Principles (Geometry)
2.1 Size Class and Hinge Reserved Region
Here are some simulator-measured values (may change with system version):
| Posture | window | horizontal size class | vertical bar | page safe area (top/left/bottom/right) |
|---|---|---|---|---|
| inner landscape | 951×669 | regular | trailing | 0 / 0 / 34 / 84 |
| inner portrait | 669×951 | regular | none | 82 / 0 / 82 / 0 |
| outer screen | 466×678 | compact | trailing | 0 / 0 / 34 / 84 |
Two takeaways:
- Inner portrait is also regular. So "straight phones and outer screen stay unchanged" can only be guaranteed by compact; inner portrait column count must be decided by the width algorithm, and size class cannot distinguish landscape from portrait.
- The hinge does not appear in size class; read it from the reserved region.
The hinge comes from reservedRegions(kind: .division, options: .includeInactive), each region has a frame and isActive. On inner landscape, the hinge is vertical, x 455.5, width 40; on inner portrait, the hinge is horizontal, y 455.5, height 40 (simulator-measured, may change). The aspect ratio tells the direction. Full-open isActive is false; half-fold is true.
Two pitfalls in this API:
- It becomes readable about 1 second after launch, with no change notification (simulator-measured, may change). On cold start, the first layout reads empty. If you cache the "no hinge" conclusion, it never updates after the region becomes ready. So we recompute the judgment on every layout and cache only an affirmative conclusion ("there is a hinge"), never a negative one.
- Read it from the window's root view. A list's supplementary view and views in early startup cannot read the region; always read from the window's root view.
2.2 Vertical Bar in Safe Area: Window vs. Page
The system vertical bar is directly in the safe area. On inner landscape, window is 951 wide, and the inset on the bar side is 84 (simulator-measured, may change). This gives a convenient conclusion: the column-count algorithm only needs the container's effective width, no extra deduction for the bar. On inner landscape, the list's available width is 867 (951 − 84), grid computes 4.7 columns → 4, list 2.4 → 2; on inner portrait, available width is 669, grid 3.6 → 3, list 1.8 → 1 (all derived from simulator-measured widths).
Content in a page only needs to horizontally pin to safeAreaLayoutGuide; the background spreads under the bar, and scrollable content stops at the bar edge, exactly as HIG requires.
But "bar in safe area" has to distinguish whose safe area:
- Non-split, outer screen, right half split: the bar inset is in the window's own safe area; both window and all pages can read it.
- Left half split: the window's safe area on the bar side is 0; the bar space is given only to pages inside the navigation controller. Measured: page left 84, window left 0, window 469×669 (simulator-measured, may change).
So "has bar but window safe area is 0 on that side" is the left-half-split criterion. In that case, elements laid out against the window safe area (e.g., a window-level TabBar calculation) won't match the page, so they need separate handling. Also, when taking bar width, only read the parent controller view's safe area (minus the parent controller's own additional), not the page's own; the reason is in the bug-fix section.
2.3 Why Even Columns Only Apply to a Vertical Hinge
The only purpose of even columns is: in half-fold, each side holds an integer number of columns, and the crease falls in the column spacing. This works only when the hinge vertically splits content, i.e., inner landscape.
In inner portrait, the hinge crosses the screen horizontally, cutting between rows. Whether column count is 2 or 3, the crease falls near some row, so even columns bring no benefit. Conversely, if portrait also forces even: available width 669 gives 3.6 columns; taking even gives only 2 columns, each card about 310pt wide, 1.9× the straight phone's 163pt. Covers get huge, and only a few books fit on screen. Switching to 3 columns gives cards about 201–204pt, close to the 196–199pt from 4 columns in landscape (all simulator-measured, may change).
So the criterion is "take even only when a vertical hinge exists." Reading the region with .includeInactive makes full-open and half-fold produce the same column count; if the hinge hasn't been read yet (early startup), treat it as "no vertical hinge." Inner landscape raw result 4.7 → 4 is already even, so this fallback doesn't affect landscape.
2.4 Vertical-Bar Criterion Is Lost During Navigation Transitions
The system gives verticalBarEdge based on the page's position: only when the page covers the screen edge on the bar side does the bar criterion apply.
With a right-side bar, during an interactive pop, the previous page slides in from the left, starting at x −277 (simulator-measured, may change), so it never touches the right edge. Its verticalBarEdge is nil for the entire gesture, and only restores at x 0. With a left-side bar, the previous page still covers the left edge, so the criterion isn't lost.
If a page responds to the criterion in real time, the symptom is: at the start of the back gesture, the button drifts from the bar to the top horizontal row, the nav bar height bounces between 64 → 48 → 64, and content jumps left/right. So during transitions, a page must suspend its response to the bar criterion, and after the transition ends, take the latest criterion once; if it's the same as already applied, do nothing. Details are in the wrapper design section.
2.5 Inner/Outer Screen Switch: Trait Changes First, Bounds Later
When unfolding or closing, traits (including size class) change first, and the window size changes about 60ms later (simulator-measured, may change). In between, self-inconsistent combinations appear, such as "951 wide + no bar" or "compact + leading bar + left safe area 148", which stabilize after tens of milliseconds.
This is a problem for compositional layout. Taking inner-to-outer as an example: at the frame where size class becomes compact, the list bounds are still the old 871; the section provider computes 2 columns at 413 wide; then bounds become 386, the system thinks layout has already been computed and doesn't call the section provider again, so cards stay at 413 and overflow the screen.
Two conclusions:
- The judgment must not look at the trait value only at the moment it changes; use the actual width at layout time.
- The layout object must force recomputation when the width differs from the last layout, without relying on the system to decide. That's the origin of
AppCompositionalLayoutbelow.
3. Architecture Design
3.1 Module Overview
Duo-related code lives in one directory, split into five parts:
| Module | Responsibility |
|---|---|
Environment DuoEnvironment |
bar side, size class, hinge (active / vertical / horizontal), top-row offset, status-bar right offset, bar trim amount, system vertical bar width, trait observation, diagnostic Posture |
Column policy ColumnPolicy |
pure functions: available width → column count, column width |
Size constants DuoMetrics |
design base 375, derived min column width, grid/list two specs, status-bar measured constants |
| Bar components | NavBarAxisResolver (horizontal/vertical decision), VerticalActionRail (bar button column), TabBarLayoutResolver (dual-pill placement, pure value type) |
| Debug self-check | DuoGeometryProbe (prints bar region, reserved region, horizontal-scrolling view count), DuoSelfCheck (aggregates each module's selfCheck()), dev builds only |
Dependencies are one-way: DuoMetrics and DuoEnvironment are at the bottom; ColumnPolicy only consumes numbers and traits; bar components depend on environment; debug modules are read-only. Outside the modules there are three integration points: AppCompositionalLayout (forces recompute on width change), page base class AppViewController (bar button arrangement, trim writing, transition suspension), and AppTabBarController (tab root page trim, hard-shadow protection on rotation).
3.2 Environment: DuoEnvironment
Pages don't read traits directly, nor call reserved region, everything goes through DuoEnvironment. The two most basic judgments are below; the APIs used are all iOS 27.1+. On lower versions everything is treated as "no bar, no hinge," so layout equals a straight phone.
import UIKit
enum DuoEnvironment {
enum VerticalBarEdge { case leading, trailing }
/// Which side the system vertical bar is on; nil = no vertical bar (iOS 27.1+)
static func verticalBarEdge(for traits: UITraitCollection) -> VerticalBarEdge? {
guard #available(iOS 27.1, *) else { return nil }
switch traits.verticalBarEdge {
case .leading: return .leading
case .trailing: return .trailing
default: return nil
}
}
/// Whether a vertical hinge exists (height > width, includes inactive, same for full-open and half-fold)
static func hasVerticalDivision(in view: UIView) -> Bool {
guard #available(iOS 27.1, *) else { return false }
return view.reservedRegions(kind: .division, options: .includeInactive)
.contains { $0.frame.height > $0.frame.width }
}
/// Column algorithm has no view; read from the active window's root view uniformly
static func hasVerticalDivision(in window: UIWindow?) -> Bool {
guard let window else { return false }
return hasVerticalDivision(in: window.rootViewController?.view ?? window)
}
}
The hinge direction is judged by aspect ratio: inner landscape hinge is width 40, height 669; inner portrait is width 669, height 40 (simulator-measured, may change). The same reading plus "width ≥ height" gives the horizontal hinge judgment, used for lifting the top row in inner portrait.
3.3 Column Policy: ColumnPolicy
ColumnPolicy is a pure function; inputs are only width, whether regular, whether a vertical hinge exists, and a spec. There is no UIKit state, so it can self-check directly against fixed values.
import UIKit
enum ColumnPolicy {
static func columns(availableWidth: CGFloat, isRegularWidth: Bool,
hasVerticalDivision: Bool = true, spec: ColumnSpec) -> Int {
let baseline = max(spec.baseline, 1)
let upper = max(spec.maxColumns, baseline)
// compact returns baseline directly: straight phones, outer screen, narrow split windows stay pixel-unchanged
guard isRegularWidth, availableWidth > 0, spec.minimumColumnWidth > 0 else { return baseline }
let raw = Int(((availableWidth + spec.spacing)
/ (spec.minimumColumnWidth + spec.spacing)).rounded(.down))
guard raw > baseline else { return baseline }
// default even; grids keep odd when no vertical hinge (inner portrait)
let keepsOdd = spec.allowsOddColumnsWithoutVerticalDivision && !hasVerticalDivision
let adjusted = keepsOdd ? raw : raw - raw % 2
return min(max(adjusted, baseline), upper)
}
static func columnWidth(availableWidth: CGFloat, columns: Int, spacing: CGFloat) -> CGFloat {
let count = CGFloat(max(columns, 1))
return max(floor((availableWidth - spacing * (count - 1)) / count), 0)
}
}
hasVerticalDivision defaults to true, meaning when unknown, the most conservative "take even" is used. In actual integration, there's also a trait version: read size class from traitCollection, and only read the hinge when the spec allows odd columns and is regular; list specs don't touch the reserved region. Available width uses effectiveContentSize.width − 2 × page margin; the bar is already deducted in the safe area.
3.4 Size Constants: DuoMetrics
The only design size in DuoMetrics is a base width of 375; the minimum column width derives from it, meaning "the column width on a straight phone at 375 is the minimum width each column must have":
import UIKit
struct ColumnSpec: Equatable {
var baseline: Int // current column count under compact
var maxColumns: Int
var minimumColumnWidth: CGFloat
var spacing: CGFloat
var allowsOddColumnsWithoutVerticalDivision = false
}
enum DuoMetrics {
static let designBaseWidth: CGFloat = 375
/// (375 − 2 × page margin − (baseline − 1) × column spacing) / baseline
static func minimumColumnWidth(baseline: Int, spacing: CGFloat, horizontalInset: CGFloat) -> CGFloat {
let count = CGFloat(max(baseline, 1))
return (designBaseWidth - horizontalInset * 2 - spacing * (count - 1)) / count
}
static func gridSpec(spacing: CGFloat, horizontalInset: CGFloat = 16) -> ColumnSpec {
var spec = ColumnSpec(baseline: 2, maxColumns: 4,
minimumColumnWidth: minimumColumnWidth(baseline: 2, spacing: spacing,
horizontalInset: horizontalInset),
spacing: spacing)
spec.allowsOddColumnsWithoutVerticalDivision = true
return spec
}
static func listSpec(spacing: CGFloat, horizontalInset: CGFloat = 16) -> ColumnSpec {
ColumnSpec(baseline: 1, maxColumns: 2,
minimumColumnWidth: minimumColumnWidth(baseline: 1, spacing: spacing,
horizontalInset: horizontalInset),
spacing: spacing)
}
}
With page margin 16 and column spacing 16, grid minimum column width is (375 − 32 − 16) / 2 = 163.5, list is 343. Grid baseline is 2 columns, max 4; list baseline is 1, max 2. The two status-bar measured constants (34 / 120) are also placed here, centralized for easy review on system upgrades.
3.5 Vertical Bar Components
NavBarAxisResolver: the single criterion for whether navigation buttons are horizontal or vertical; it readsverticalBarEdge. Immersive pages (reader) have an additionalplacementjudgment distinguishing vertical bar, top bar, or in-page placement; normal pages don't call it.VerticalActionRail: the button column inside the vertical bar, three button groups: back/close and function buttons pinned to top, another group pinned to bottom (only for pages without a vertical TabBar, to avoid competing with TabBar). Buttons are uniformly 40×40, spacing 20; vertically pinned to the system bar layout region, horizontally aligned to the TabBar pill center when the system bar width is available (see end of 4.3).TabBarLayoutResolver: for the two pills (main tab, search), decides horizontal/vertical placement, pure value type using physical coordinates. Vertical is bottom-up; when a horizontal hinge passes through the whole group, move above the hinge; fall back to horizontal when it doesn't fit.
3.6 Debug Self-Check
DuoGeometryProbe and DuoSelfCheck compile only in dev builds and live in the debug menu. The probe prints the current page's bar region, reserved region, statusBarFrame, and the count of vertical lists whose horizontally scrollable range is greater than 0; this count must be 0 at acceptance. The self-check aggregates each pure function's selfCheck() and lists all failures rather than crashing on assertion.
4. Wrapper Design
4.1 Pure Functions + selfCheck Instead of Tests
Everything that can be a pure function is one: column count, column width, minimum column width, trim formula, TabBar placement. View-dependent parts (reading traits, regions, safe area) only "fetch values" and pass them to pure functions. The benefit is that even without a test target, verification is possible; self-check inputs are derived from measured widths, e.g., available width 835 (867 minus both side margins) should produce 4 grid columns.
4.2 Single Decision Outlet
Each type of decision has only one outlet: the bar reads NavBarAxisResolver / DuoEnvironment.verticalBarEdge, column count reads ColumnPolicy, top-row offset reads DuoEnvironment.topRowOffset, bar width reads DuoEnvironment.systemVerticalBar. Pages never judge posture themselves. This way, when system behavior changes (e.g., true bar width isn't 84), only one place changes.
4.3 Move the Same Button Instance, Don't Rebuild
When the vertical bar appears, icon-only buttons in the custom nav bar are moved into VerticalActionRail; when it disappears, they move back. It's always the same instance, so tap callbacks, two-state icons, and disabled states need no re-binding, and call sites don't change a single character. Buttons with text stay in the horizontal row (Apple's rule).
Three things to handle when moving:
- Size protocol. Vertical is uniformly 40; horizontal may be 48 or 64. The column doesn't know concrete button types, only a protocol: record the original diameter on entry, scale to 40, restore on removal.
- Clear the button's own size constraints first. Constraints like width/height have the button itself as
firstItem;removeFromSuperview()doesn't take them away. If not cleared, the button enters the column at 48 and overrides 40. - When returning to horizontal, clear the column's positioning constraints. The constraints that pin the column to the system bar region remain after the bar disappears and conflict with the new layout; so when all three groups are empty, remove them. Also, the "fill to bottom" equality is lowered to
.high, with a required "not beyond bottom": in intermediate frames of folding or rotation, bar region and window size may not sync, and a required equality can be unsolvable.
The following snippet uses only native constraints (iOS 27.1+; .bar(onEdge:) requires explicit NSDirectionalRectEdge, otherwise there's an overload ambiguity with UIRectEdge):
import UIKit
protocol VerticalBarResizableButton: UIView {
var circleDiameter: CGFloat { get }
func updateCircleDiameter(_ diameter: CGFloat)
}
final class VerticalActionRail: UIStackView {
private var pinned: [NSLayoutConstraint] = []
private var originalDiameters: [ObjectIdentifier: CGFloat] = [:]
func setItems(_ items: [UIView]) {
arrangedSubviews.forEach { view in
if let button = view as? VerticalBarResizableButton,
let d = originalDiameters.removeValue(forKey: ObjectIdentifier(button)) {
button.updateCircleDiameter(d)
}
view.removeFromSuperview()
}
items.forEach { item in
item.removeConstraints(item.constraints.filter { $0.firstItem === item && $0.secondItem == nil })
if let button = item as? VerticalBarResizableButton {
originalDiameters[ObjectIdentifier(button)] = button.circleDiameter
button.updateCircleDiameter(40)
}
addArrangedSubview(item)
}
if items.isEmpty { NSLayoutConstraint.deactivate(pinned); pinned = [] } // clear positioning constraints when returning to horizontal
}
@available(iOS 27.1, *)
func pin(to host: UIView, edge: NSDirectionalRectEdge) {
let guide = host.layoutGuide(for: .bar(onEdge: edge, extent: 40))
NSLayoutConstraint.deactivate(pinned)
translatesAutoresizingMaskIntoConstraints = false
let fill = bottomAnchor.constraint(equalTo: guide.bottomAnchor)
fill.priority = .defaultHigh // filling is a "hope", can be sacrificed in intermediate frames
pinned = [centerXAnchor.constraint(equalTo: guide.centerXAnchor),
topAnchor.constraint(equalTo: guide.topAnchor),
bottomAnchor.constraint(lessThanOrEqualTo: guide.bottomAnchor), fill]
NSLayoutConstraint.activate(pinned)
}
}
In the real implementation, the rail is made of three groups of stack views; taps in empty areas pass through. Horizontally, when a system bar width exists, alignment follows the TabBar pill center (48, simulator-measured, may change), not the bar geometric center 42, so buttons line up with the vertical TabBar on the same axis.
4.4 Trim Formula, Read Only from Parent Controller's Safe Area
After the bar gives way, the distance from pill to content is larger than the distance from pill to screen edge, making content look too far from the bar. The approach is to write a negative value into additionalSafeAreaInsets on the bar side to pull content closer to the bar:
trim = barWidth + pageInset − 2 × pillCenter
With bar width 84, pageInset 16, pillCenter 48 (simulator-measured, may change): 84 + 16 − 96 = 4, so left-half content start goes from 100 to 96. If any of bar width, pill diameter, or margin changes, this recalculates automatically; nothing is hardcoded to 4.
Bar width is read only from the parent controller view's safe area minus the parent controller's own additional; when there's no parent (modal root page), read the window's safe area. Never read the page's own safe area, because right after the page writes additional, the safe area hasn't been recomputed yet; the two are out of sync, and the error amplifies each round. When writing, first withdraw the previously written amount, then write the new value; don't touch top/bottom insets written elsewhere.
import UIKit
enum VerticalBarTrim {
/// Which side and how wide the bar is: read the parent (nav controller) safe area, doesn't move with page transitions
static func systemBar(for vc: UIViewController, isLeft: Bool) -> CGFloat {
let insets: UIEdgeInsets
if let parent = vc.parent, parent.isViewLoaded {
let total = parent.view.safeAreaInsets, extra = parent.additionalSafeAreaInsets
insets = UIEdgeInsets(top: 0, left: total.left - extra.left,
bottom: 0, right: total.right - extra.right)
} else {
insets = vc.view.window?.safeAreaInsets ?? .zero
}
return isLeft ? insets.left : insets.right
}
static func trim(barWidth: CGFloat, pageInset: CGFloat, pillCenter: CGFloat) -> CGFloat {
guard barWidth > 0 else { return 0 }
return max(0, barWidth + pageInset - pillCenter * 2)
}
/// Withdraw last written amount first, then write new; only touch the bar side
static func apply(_ trim: CGFloat, isLeft: Bool, to vc: UIViewController, last: inout CGFloat) {
var insets = vc.additionalSafeAreaInsets
if isLeft { insets.left += last - trim } else { insets.right += last - trim }
vc.additionalSafeAreaInsets = insets
last = trim
}
}
A child controller's parent controller safe area already contains the page's trim (e.g., 80), so feeding that into the formula gives 0, and nothing is double-trimmed. In the snippet, "which side" is converted to physical left/right using the bar edge and layout direction in the actual implementation.
4.5 Only Two Writers
additionalSafeAreaInsets is something many parties might want to write (TabBar clearance, bar trim). The rule is only two writers, each owning one category:
- Tab stack root pages are written by
AppTabBarController, merged with the pill clearance into one write. The trim is computed uniformly from the bar width measured on the selected tab and written to all root pages. Non-selected root pages are not on screen, can't measure the bar themselves and get 0, causing content to jump 4pt horizontally when switching tabs. - Other pages are written by
AppViewController, which remembers the last written amount, first withdraws then writes.
The nav controller's own additional is fixed at 0, so its safe area is exactly what the system gives; that's what makes 4.4's safe read of the parent safe area possible. No third writer is allowed.
4.6 Layout That Forces Recompute on Width Change
As 2.5 explained, on inner/outer switch the size class changes first, bounds later; the system computes with the old width and won't call the section provider again. The solution is a small subclass that remembers the width used for the last actual layout and invalidates when it differs:
import UIKit
final class AppCompositionalLayout: UICollectionViewCompositionalLayout {
/// The width used in the last real layout
private var preparedWidth: CGFloat?
override func prepare() {
super.prepare()
preparedWidth = collectionView?.bounds.width
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
// compare only width: scrolling changes origin, won't cause extra invalidation
if let preparedWidth, preparedWidth != newBounds.width { return true }
return super.shouldInvalidateLayout(forBoundsChange: newBounds)
}
}
All lists that compute layout by container width use this instead of UICollectionViewCompositionalLayout; initialization syntax stays the same. A companion requirement: the height cache key must include the column count, otherwise a height computed for 2 columns is reused for 4.
4.7 Suspend During Transition, Not Freeze Constraints
From 2.4, during a back gesture the previous page loses the bar criterion. The handling: if a page has already appeared once and has a transitionCoordinator during viewWillAppear / viewWillDisappear, suspend its response to the bar criterion (don't rebuild nav bar, don't change safe area, don't recompute the nav bar top row). After the transition ends, in a no-animation block, take the latest criterion once; if it matches what's already applied, do nothing. First-time pages entering the stack are not suspended.
import UIKit
class AppViewController: UIViewController {
private var hasAppearedOnce = false
private(set) var isHoldingForTransition = false
private var appliedBarEdge: Int? // already-applied bar criterion (illustrative)
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); holdIfNeeded() }
override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); holdIfNeeded() }
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated); hasAppearedOnce = true }
private func holdIfNeeded() {
guard hasAppearedOnce, !isHoldingForTransition, let coordinator = transitionCoordinator else { return }
isHoldingForTransition = true
coordinator.animate(alongsideTransition: nil) { [weak self] _ in self?.endHold() }
}
private func endHold() { // includes interactive back cancellation
isHoldingForTransition = false
guard view.window != nil else { return } // already left screen: wait for next appearance
UIView.performWithoutAnimation {
if currentBarEdge() != appliedBarEdge { reloadBars() }
}
}
/// In bar trait change callback, first check: don't respond while holding
func verticalBarTraitDidChange() {
guard !isHoldingForTransition else { return }
reloadBars()
}
func currentBarEdge() -> Int? { nil } // real implementation reads verticalBarEdge (iOS 27.1+)
func reloadBars() { appliedBarEdge = currentBarEdge() }
}
We first tried "freeze constraints, unfreeze later," which was rejected: it only blocked the buttons; the nav bar whitespace and the page's own top row still read the criterion independently, so when unfreezing they moved again, and you could see jitter on the back page. What we suspend is the act of "responding to the criterion" itself, so all layouts that depend on the criterion stop together. If a page computes its own top row from the bar criterion, it must override a suspend/resume callback and do a sync on resume. The immersive page (reader) does not inherit this base class and currently has no such protection.
5. Covered Scenarios
The geometry values in this table are all simulator-measured, may change with system version (iOS 27.1 iPhone Duo simulator; no real-device data yet).
| Posture | size class / bar | top row | column count (grid / list) | nav button position | TabBar | notes |
|---|---|---|---|---|---|---|
| straight phone | compact / no bar | unchanged | 2 / 1 (baseline) | top horizontal | bottom horizontal dual pill | design baseline, pixel-unchanged |
| outer screen | compact / bar (width 84) | top safe area 0, shift down 16 | 2 / 1 (compact always baseline) | icon-only buttons enter bar, top-down; text buttons stay horizontal | vertical, bottom-up in lower bar | content stops at bar edge, trim 4 on bar side |
| inner portrait | regular / no bar, horizontal hinge | lifted into status bar row: row center aligns with Wi-Fi center, right side avoids time / Wi-Fi / battery and camera; nav bar total height 80 | 3 / 1 (no vertical hinge, grid not even) | top horizontal (reader goes to top bar region) | bottom horizontal | status bar icon position has no public API, uses measured 34 / 120, not real-device verified |
| inner landscape | regular / bar, vertical hinge | top safe area 0, shift down 16 | 4 / 2 (even) | enter bar | vertical | vertical lists use .scrollableAxes, no horizontal spring |
| left half split | bar, but window safe area 0 on bar side | first vertical button center aligns with title row center | compact → baseline (2 / 1), ensured by algorithm; actual size class and column count under split unverified | all pages (including secondary) keep left clearance band, icon buttons align in band, horizontal center 48 | root page TabBar in lower band | content start 100 → 96; status bar embedded criterion unverified |
| right half split | bar clearance in window's own safe area | same as non-split bar posture | same as left half: compact baseline, actual column count unverified | pin to system bar region | same as bar posture | doesn't hit left-half criterion; status bar embedded criterion unverified |
| half-fold | hinge active | unverified (expected same as corresponding full-open, not measured) | same as full-open (criterion includes inactive hinge) | unverified (expected same as corresponding full-open, not measured) | horizontal hinge through whole group moves above hinge; fall back to horizontal if doesn't fit | custom overlay avoidance pending; book/tabletop actual effect unverified (simulator can't control posture) |
On full-open, the hinge is inactive; content is evenly divided across the full width without avoidance. On half-fold, overlays like confirm dialogs and Toasts ride on the hinge; currently only the version-update dialog and confirm dialog have width whitespace, and a unified avoidance outlet isn't done.
5.1 Acceptance Matrix
For any layout change, screenshot-compare every item:
| Dimension | Requirement |
|---|---|
| straight phone | identical to before change |
| outer screen | identical to before change; bar/TabBar normal; content doesn't cross bar |
| inner portrait | grid 3 columns / list 1 column; horizontal bar normal |
| inner landscape + bar | waterfall 4 columns / list 2 columns; content doesn't cross bar; no horizontal spring |
Also run repeated-switch checks: expand, close, rotate back and forth; no crash, no ghosting; use geometry probe to confirm horizontally over-scrollable view count is 0. Whether root-page list jumps during fold/unfold and whether vertical TabBar tap behavior works are currently only checked via screenshots, not verified; full-page, four-environment regression hasn't been done either.
6. Bug Fix Summary
Below are the problems fixed during adaptation, grouped by topic. Each entry has symptom, root cause, and fix. Values are simulator-measured, may change with system version.
6.1 Safe Area and Trim
Trim amplifies round by round; left-half root page content fills screen on pop
- Symptom: in left-half split, popping back to root page, trim goes from −4 to −68, amplifying to −324; root page safe area gets truncated to 0, content instantly fills screen.
- Root cause: bar width was computed as "page safe area − page additional." Right after writing
additional, safe area hasn't recomputed; the two are out of sync, so the difference is wrong; next round writes based on the wrong value, error amplifies. During transitions the page is moved away, and its safe area briefly becomes 0. - Fix: read bar width only from parent controller view's safe area, minus the parent controller's own additional. The nav controller's additional is fixed at 0 and doesn't move during transitions, so its safe area is exactly the system value. Modal root pages without a parent read the window's safe area.
Content jumps 4pt left/right on tab switch
- Symptom: switching to another tab, content first uses 0 trim, then becomes −4.
- Root cause: non-selected root pages aren't on screen, can't read the bar, so their computed trim is 0.
- Fix: TabBar container measures the bar on the selected tab and computes trim uniformly, writing it to all root pages.
TabBar clearance stacks to 148
- Symptom: in left-half split, a blank gap appears between content and pill; pill center shifts to 40 (48 with right bar).
- Root cause: in left-half, system only gives 84 bar clearance to pages inside the nav controller; window and TabBar's own left safe area are 0. TabBar only looks at itself, treats bar as 0 width, adds 64 to root page, which stacks with the system's 84 to 148.
- Fix: pill clearance takes the larger of the content page's system safe area. After fix, bar width 84, pill center 48, supplementary clearance 0, mirroring right-bar case.
Pill-to-content distance differs from pill-to-screen-edge
- Symptom: in bar posture, distance from pill outer edge to screen edge and from inner edge to content differ; looks off-center.
- Root cause: content only pins to safe area, doesn't account for pill's actual position inside bar.
- Fix: trim computed by formula: barWidth + pageInset − 2 × pillCenter-from-screen-edge (84 / 16 / 48 gives 4), written as negative into
additionalSafeAreaInsetson the bar side. Before writing, withdraw the previously written amount.
Page content crosses the vertical bar
- Symptom: lists, horizontal scroll cards, and page headers extend under the bar.
- Root cause: content container horizontally pins to superview, not safe area.
- Fix: page base class content container pins horizontally to
safeAreaLayoutGuide. Per HIG, background spreads; scrollable content insets.
6.2 Bar Constraints and Buttons
Residual constraints after bar disappears squeeze content
- Symptom: after folding, rotating, or bar switching sides, body container squeezed into narrow strip, button column goes outside screen.
- Root cause: vertical button column's constraints anchor to the system bar layout region. After bar disappears these constraints remain and conflict with new-posture regions; Auto Layout breaks constraints unpredictably.
- Fix: in the method that sets buttons, when all three groups are empty, clear the button column's own constraints entirely. Clearing is inside the component, not dependent on callers remembering to "unpin."
Button's own size constraint not cleared; stays 48 in bar
- Symptom: reader button in bar measures 48 wide; the required 40 doesn't take effect.
- Root cause: constraints like
make.size.equalTo(...)have the button itself asfirstItemand attach to the button;removeFromSuperview()doesn't take them away. - Fix: before handing a button to the vertical rail, clear constraints whose
firstItemis the button itself; size is then determined byintrinsicContentSize. A size protocol handles scale-to-40 on entry and restore on removal.
Intermediate-frame constraint unsolvable during posture switch
- Symptom: occasional constraint conflicts during posture switch, button positions wrong.
- Root cause: in intermediate frames, bar region and window size may not update synchronously; a required "fill to bottom" equality can be unsolvable at that moment.
- Fix: lower that equality to
.high, and add a requiredbottom ≤. Worst case degrades to "pin top + not below bottom."
Unpinned button column spams constraint conflict logs
- Symptom: console repeatedly outputs
Unable to simultaneously satisfy constraints. - Root cause: when horizontal (or after clearing constraints when returning to horizontal), the button column has no fixed position; UIKit converts a 0-height frame into an autoresizing
height == 0constraint, conflicting with the internal required "groups spacing ≥ 20." - Fix: disable
translatesAutoresizingMaskIntoConstraintson the button column.
Vertical button position depends on measured constants
- Symptom: early versions positioned buttons by measured distances; switching postures required re-measuring.
- Root cause: system bar layout region not used.
- Fix: button column uses
layoutGuide(for: .bar(onEdge:extent:)); this region already clears status bar and camera. Left-half is an exception: system region falls outside the clearance band; horizontal alignment measures pill center from screen edge instead.
6.3 Scrolling and Layout
Vertical list shows horizontal spring
- Symptom: on inner landscape, vertical list can be dragged horizontally, showing an 84pt spring.
- Root cause:
contentInsetAdjustmentBehavior = .alwaysadds the bar safe area into left/right insets; horizontal scrollable range becomes >0. - Fix: switch to
.scrollableAxes+alwaysBounceVertical = true; the bar is handled by compositional layout insetting the section per safe area. Geometry probe added "horizontally over-scrollable view count" check. An earlier attempt to lock horizontal scrolling via KVO was abandoned.
Cards stay at old width after inner/external switch
- Symptom: switching from inner to outer, cards overflow screen; returning to previous page and re-entering recovers them.
- Root cause: size class changes first, bounds later. When trait change triggers recompute, bounds are still inner width (871); section computes 2 columns at 413 wide for "871 + compact"; then bounds become 386, system doesn't re-call section provider.
- Fix: add a compositional layout subclass that remembers last layout width and invalidates when width differs. Only compares width; scrolling doesn't trigger. Business lists all switch to it; height cache key includes column count.
Paged list width wrong
- Symptom: after inner/outer switch or rotation, two pages of bookshelf have wrong widths; measured cell 669, list 817 (817 = 669 + 148).
- Root cause: paging library had explicitly written list frame at new width; child page root view's default autoresizing added the width difference again.
- Fix: set paging list root view
autoresizingMask = []; size only honors the frame written by paging library.
Inner portrait grid cards too large
- Symptom: inner portrait grid only 2 columns, cards ~310pt wide, nearly 1.9× straight phone.
- Root cause: column count always even, but even only matters when hinge vertically splits content; inner portrait hinge crosses rows, odd/even no effect.
- Fix: take even only when a vertical hinge exists; criterion reads hinge region including inactive, so full-open and half-fold same column count. Home waterfall, full-book waterfall, bookshelf grid, and search ranking become 3 columns, cards ~201–204pt; lists unaffected.
6.4 Timing and Caching
Hinge judgment cached negative conclusion; cold-start top row not lifted
- Symptom: inner portrait cold start, home logo row stays in place, blank space above; reproduced 4 out of 4 cold starts.
- Root cause: hinge reserved region readable ~1s after launch, no change notification. Original implementation cached false for 1s; the layout after region became ready happened to fall inside the cache window, read old false, no further layout triggered.
- Fix: cache only true; false re-checked every time. Judgment recomputed at layout; region read from window root view (supplementary views and early window can't read).
List at top offset when changing contentInset shifts twice
- Symptom: after posture switch, list top content offset shifted by twice the difference.
- Root cause: system may have already adjusted offset; subtracting difference again moves it twice.
- Fix: when at top, align directly to new top:
offset = -adjustedContentInset.top. During pull-to-refresh, an attempt to change inset only pushed top content off screen and was reverted; switching postures during refresh is a known limitation.
Third-party debug tool interferes with main window geometry
- Symptom: in dev build, after inner/outer switch, window stays old size, console has UIKit "BUG IN CLIENT OF UIKIT" warning; inner rotation also shows black edges.
- Root cause (current judgment): debug tool uses
UIScreen.main.boundsto create its own window. Duo has two screens;UIScreen.mainisn't necessarily the scene's screen, disturbing main window geometry. Only affects dev builds. - Fix: add debug overlay toggle, default off on Duo; after cold start hide debug window and disconnect its windowScene; also hide on rotation.
6.5 Transitions
Hard shadow reveals outside card on rotation
- Symptom: on rotation or Duo inner rotation, solid hard shadow shows entirely outside card.
- Root cause:
shadowPathdoesn't participate in UIView implicit animation; at transition start it jumps to final size. - Fix: large cards whose size changes with screen width follow a "hide shadow during transition" protocol. TabBar container in
viewWillTransitionpresses shadow opacity to 0, restores in completion (including cancellation; on continuous rotation, only restore after last one ends). Grid cards and search result cards adopted it. Did not switch to a backing layer (outer-edge antialiasing differs from layer shadow), and did not animateshadowPath(depends on undeclared animation key).
Button drift and nav bar jitter on gesture back
- Symptom: with right-side bar, gesture back moves button to top first, then back to bar on release; nav bar height bounces 64 → 48 → 64; trim jumps 4pt.
- Root cause: system gives bar criterion based on page position. During gesture back, previous page moved to x −277, doesn't touch bar; criterion nil for entire segment. For left-side bar, previous page still covers left edge; no problem.
- Fix: pages that have appeared once suspend during navigation transitions (when
transitionCoordinatorexists), not responding to criterion changes. After transition, in no-animation block take latest criterion once; if equal to already applied, do nothing. First-time pages are not suspended. "Freeze constraints, unfreeze later" only blocked buttons; it moved again on unfreeze, abandoned. Reader doesn't inherit page base class; this protection not yet covered.
6.6 Others
Bottom drawer pressed by upper bar system elements
- Symptom: drawer full-width, right side extends under bar, at y 96 it's pressed by time / Wi-Fi.
- Root cause: drawer top whitespace set only for no-bar case.
- Fix: when bar exists, top whitespace increased to 130. Criterion is "has bar," not device model.
Reader guide bubble points wrong
- Symptom: in split-screen, guide card bubble tail doesn't point to target button.
- Root cause: reader doesn't go through page base class; button enters clearance band, guide layer still places at original position.
- Fix: reader body and control layer pin horizontally to safe area; in split-screen, button column enters clearance band; guide card horizontally follows highlighted button.
Left-half detour
- Symptom: initially made secondary pages full-bleed, buttons stayed top, wrote negative into additional to offset the whole 84.
- Root cause: inconsistent with root page and TabBar clearance; layout jumped on page switch.
- Fix: abandoned; all pages keep clearance band, buttons and TabBar share pill center, first button aligns with title row.
6.7 Lesson: Get Data First, Then Change Code
When debugging "window stays old size, paged width wrong," the first three rounds were changed based on symptoms: invalidate paged layout and align to whole-page offset, pin list constraints to cell, sync list frame for three consecutive frames. All three failed; the second round also caused whole-page misalignment and black edges. Force-changing window.frame could mask the symptom but wasn't adopted. Later, each round used geometry probe to print actual frames of window, paged container, and list before acting; this located the two root causes: autoresizing stacking and the debug tool's window. Duo's transition intermediate states can have tens-of-milliseconds self-inconsistent combinations (e.g., 951 wide without bar); screenshots alone are misleading; logged timing data is more reliable than symptoms.
7. Optimization Suggestions
Each item below is a known, recorded gap. "Pending" means not done; "unverified" means done but no measured conclusion.
7.1 TabBar Vertical Use System Layout Region (pending)
- Current: vertical TabBar position uses two simulator-measured constants: top limit 96, pill center 48. Reason: under bar
statusBarFramereports only 2pt, unusable. Nav bar button column already useslayoutGuide(for: .bar(onEdge:extent:)); TabBar hasn't. - Direction: switch to same as button column, get position from system bar layout region, delete these two constants. After migration, re-verify pill center under left-half, because button column horizontally aligns to that center.
7.2 Status Bar Icon Position Constants Need Real-Device Verification (unverified)
- Current: inner portrait top row must align with Wi-Fi and avoid right icons. Status bar icon position has no public API, so two measured constants: Wi-Fi center 34 above safe area top, right icon cluster 120 wide. Both from iOS 27.1 simulator pixel-measured, not verified on real device. 120 is riskier: cellular signal, battery percentage, Live Activity widen cluster; top row may overlap icons.
- Direction: on first real-device test, use geometry probe to print status bar info, then measure icon position; re-verify on each system upgrade. If system later provides status bar layout region, switch to system API.
7.3 Half-Fold Overlay Avoidance (pending)
- Current: full-open hinge inactive, no avoidance needed. Half-fold hinge active, centered overlays (confirm dialogs, Toasts) ride on hinge (landscape x 455–495). Currently only version-update and confirm dialogs get width whitespace; environment layer has no "avoid half area" outlet. Drawer and reader overlays handed to other owners. Simulator can't control book/tabletop posture, so half-fold actual effect unverified.
- Direction: add an outlet in environment layer returning available half area based on active division; overlays uniformly place against it, not letting each overlay read hinge itself.
7.4 Reader Not Covered by Transition Suspension (pending)
- Current: suspension is on page base class. Reader is immersive page, doesn't inherit; so button drift and layout jitter may still appear in reader during gesture back.
- Direction: extract suspension logic from base class into composable small component; reader integrates separately.
7.5 Reader Title Page Not Following Safe Area (pending)
- Current: title page overlay still fills entire view, doesn't horizontally pin to safe area like body and control layer; text may be pressed under bar. It's behind a default-off flag; no user-visible impact now.
- Direction: before turning on that flag, change to pin horizontally to safe area, consistent with other reader layers.
7.6 Hard Shadow Transition Protection Coverage Boundary
- Current: hiding hard shadow on rotation is on TabBar container's
viewWillTransition; two boundaries: modal-presented pages are not in the view tree, unprotected; cells newly displayed during transition aren't hidden. - Direction: add same entry to modal container; new cells during transition query "is in transition" at configure time to decide initial shadow opacity.
7.7 RTL Unverified (unverified)
- Current: layout constraints use leading/trailing, but RTL actual effect not tested. Direction of paged container vs segmented control, and vertical TabBar appearance have no conclusion. TabBar placement uses physical coordinates; theoretically affects only left-right order of horizontal pills.
- Direction: run acceptance matrix with RTL pseudo-language; focus on trim and button column when bar is on leading side.
7.8 Real-Device First Test and Full Regression (pending)
- Current: all geometry data from simulator, no real-device. Four-environment compile verification and full-page acceptance matrix screenshots not done. A few items only screenshots, no operation: whether root list jumps during fold/unfold, whether vertical TabBar can tap and hide after push, and split-screen status bar embedded criterion.
- Direction: on real device, screenshot-compare each acceptance matrix item; re-verify measured constants in 7.1 and 7.2.
7.9 effectiveContentSize Approximation to Re-verify (unverified)
- Current: geometry probe's
effectiveContentSizeisn't directly read; it approximates withbounds − adjustedContentInset. - Direction: print actual value in the real section provider used in layout, compare with probe result, confirm column-count derivation input has no bias.
7.10 Root Cause of Debug Tool Window Problem (unverified)
- Current: "window stays old size" root cause is judged to be debug tool using
UIScreen.main.boundsto create own window. Duo has two screens;UIScreen.mainisn't necessarily the scene's. Fix is to disable debug overlay on Duo by default. "No longer reproduces after disabling" still unverified; production config comparison biased due to different data volume. Only affects dev builds. - Direction: run a controlled on/off overlay comparison, confirm root cause before deciding to patch debug tool rather than keep it off.
7.11 Migrate selfCheck to Unit Tests
- Current: no test target; correctness of pure functions (column count, column width, trim, etc.) relies on manual
selfCheck()in Debug menu, not in CI. - Direction: create test target, migrate existing selfCheck cases as unit tests; add property-based tests for column algorithm checking invariants like "compact always baseline" and "result always in [baseline, upper]."
7.12 Conclusion
The core of Duo adaptation is guarding two lines in geometry: compact layout pixel-unchanged; under regular all dimensions derived from system APIs. Unresolved issues cluster into three categories: places without public API requiring measured constants, postures the simulator can't test, and immersive pages not on the page base class. Once a real device is available, tackle these three first.