跪拜 Guibai
← Back to the summary

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:

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:

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:

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:

  1. 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.
  2. 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:

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:

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:

  1. The judgment must not look at the trait value only at the moment it changes; use the actual width at layout time.
  2. 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 AppCompositionalLayout below.

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

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:

  1. 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.
  2. 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.
  3. 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:

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

Content jumps 4pt left/right on tab switch

TabBar clearance stacks to 148

Pill-to-content distance differs from pill-to-screen-edge

Page content crosses the vertical bar

6.2 Bar Constraints and Buttons

Residual constraints after bar disappears squeeze content

Button's own size constraint not cleared; stays 48 in bar

Intermediate-frame constraint unsolvable during posture switch

Unpinned button column spams constraint conflict logs

Vertical button position depends on measured constants

6.3 Scrolling and Layout

Vertical list shows horizontal spring

Cards stay at old width after inner/external switch

Paged list width wrong

Inner portrait grid cards too large

6.4 Timing and Caching

Hinge judgment cached negative conclusion; cold-start top row not lifted

List at top offset when changing contentInset shifts twice

Third-party debug tool interferes with main window geometry

6.5 Transitions

Hard shadow reveals outside card on rotation

Button drift and nav bar jitter on gesture back

6.6 Others

Bottom drawer pressed by upper bar system elements

Reader guide bubble points wrong

Left-half detour

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)

7.2 Status Bar Icon Position Constants Need Real-Device Verification (unverified)

7.3 Half-Fold Overlay Avoidance (pending)

7.4 Reader Not Covered by Transition Suspension (pending)

7.5 Reader Title Page Not Following Safe Area (pending)

7.6 Hard Shadow Transition Protection Coverage Boundary

7.7 RTL Unverified (unverified)

7.8 Real-Device First Test and Full Regression (pending)

7.9 effectiveContentSize Approximation to Re-verify (unverified)

7.10 Root Cause of Debug Tool Window Problem (unverified)

7.11 Migrate selfCheck to Unit Tests

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.