# iPad layout and windowing

Companion to `../SKILL.md` for the `hig-platforms` skill: how one SwiftUI
layout adapts from a narrow iPad window to a full external display. Hinged
iPhone hardware (iPhone Duo) has its own skill, `ios-iphone-duo`.

## Contents

1. [Windows on iPadOS 26](#1-windows-on-ipados-26)
2. [Size classes, not devices](#2-size-classes-not-devices)
3. [Choosing a layout tool](#3-choosing-a-layout-tool)
4. [ViewThatFits](#4-viewthatfits)
5. [AnyLayout](#5-anylayout)
6. [A custom Layout](#6-a-custom-layout)
7. [Scenes, multiple windows and the menu bar](#7-scenes-multiple-windows-and-the-menu-bar)
8. [Test matrix](#8-test-matrix)

## 1. Windows on iPadOS 26

People choose one of two presentations in Multitasking settings:

- **Full screen**: app windows fill the screen; people switch with the app
  switcher.
- **Windowed**: people resize windows freely, keep several on screen,
  reposition them and bring them to front. The system remembers size and
  placement.

What this means for layout:

- Support arbitrary sizes. Windows resize fluidly down to a minimum rather
  than snapping between preset sizes, so a layout cannot assume a short list of
  widths. Stage Manager (iPadOS 16 and later on supported iPads) already
  required this.
- `UIRequiresFullScreen` is deprecated in iPadOS 26. In windowed mode it no
  longer gives a full-screen app; the system keeps a fixed scene size and
  scales the app when people resize. Apple asks iPad apps to support
  multitasking and dynamic resizing instead.
- Keep leading toolbar items clear of the window controls that appear at the
  leading edge of the toolbar in windowed mode; move such buttons inward.
- Set a sensible minimum (and, where useful, maximum) size so the layout never
  overlaps: `UISceneSizeRestrictions.minimumSize` in UIKit,
  `.windowResizability(_:)` and `.defaultSize(_:)` on scenes in SwiftUI
  (iOS 17).
- Use standard split views: `NavigationSplitView` reflows its columns for every
  width with system transitions.

## 2. Size classes, not devices

| Context | Horizontal | Vertical |
|---|---|---|
| iPad full screen, either orientation | regular | regular |
| iPad window narrowed to a phone-like width | compact | regular |
| iPhone portrait | compact | regular |
| iPhone landscape (most models) | compact | compact |

Read `@Environment(\.horizontalSizeClass)` and `verticalSizeClass`; never
branch on the device model or on `UIDevice.current.userInterfaceIdiom` for
layout. A size class says whether there is room for more columns; the
container's actual size decides the details.

```swift
@Environment(\.horizontalSizeClass) private var widthClass

var body: some View {
    if widthClass == .compact {
        JobList(jobs: jobs)
    } else {
        NavigationSplitView {
            JobList(jobs: jobs)
        } detail: {
            JobDetailPlaceholder()
        }
    }
}
```

`NavigationSplitView` already collapses to a stack in compact width, so the
branch above is only needed when the compact design differs.

## 3. Choosing a layout tool

| Question | Tool (since) |
|---|---|
| Does a list-detail hierarchy need columns? | `NavigationSplitView` (iOS 16) |
| Which of a few fixed arrangements fits the space? | `ViewThatFits` (iOS 16) |
| Switch between stack axes while keeping state and animating? | `AnyLayout` (iOS 16) |
| Size a view relative to its container? | `containerRelativeFrame(_:alignment:)` (iOS 17) |
| A layout no stack or grid expresses? | A type conforming to `Layout` (iOS 16) |
| Readable width for long text in a wide window? | `.contentMargins` (iOS 17) or a max width |

Reach for `GeometryReader` last; it takes all offered space and couples the
view to raw sizes.

## 4. ViewThatFits

Picks the first child that fits the proposed size along the given axes.

```swift
ViewThatFits(in: .horizontal) {
    HStack(spacing: 12) {
        JobSummary(job: job)
        Spacer()
        JobActions(job: job)
    }
    VStack(alignment: .leading, spacing: 8) {
        JobSummary(job: job)
        JobActions(job: job)
    }
}
```

Each candidate is a separate subtree: state inside one is lost when the other
wins. Keep state above `ViewThatFits`.

## 5. AnyLayout

Wraps a layout type so the arrangement can change while view identity, and so
state, survives, and the change can animate.

```swift
@Environment(\.horizontalSizeClass) private var widthClass
@Environment(\.dynamicTypeSize) private var typeSize

var body: some View {
    let isNarrow = widthClass == .compact || typeSize.isAccessibilitySize
    let layout = isNarrow
        ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12))
        : AnyLayout(HStackLayout(alignment: .top, spacing: 20))

    layout {
        PartsList(parts: job.parts)
        PhotoStrip(photos: job.photos)
    }
    .animation(.smooth, value: isNarrow)
}
```

`HStackLayout`, `VStackLayout`, `ZStackLayout` and `GridLayout` are the
built-in layout types to swap between.

## 6. A custom Layout

Conform to `Layout` and implement `sizeThatFits(proposal:subviews:cache:)` and
`placeSubviews(in:proposal:subviews:cache:)`. A flow layout that wraps chips
onto new lines is the usual case:

```swift
struct FlowLayout: Layout {
    var spacing: CGFloat = 8

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxWidth = proposal.width ?? .infinity
        var origin = CGPoint.zero
        var lineHeight: CGFloat = 0
        var usedWidth: CGFloat = 0
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if origin.x > 0, origin.x + size.width > maxWidth {
                origin.x = 0
                origin.y += lineHeight + spacing
                lineHeight = 0
            }
            origin.x += size.width + spacing
            usedWidth = max(usedWidth, origin.x - spacing)
            lineHeight = max(lineHeight, size.height)
        }
        return CGSize(width: usedWidth, height: origin.y + lineHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        var origin = bounds.origin
        var lineHeight: CGFloat = 0
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if origin.x > bounds.minX, origin.x + size.width > bounds.maxX {
                origin.x = bounds.minX
                origin.y += lineHeight + spacing
                lineHeight = 0
            }
            subview.place(at: origin, proposal: ProposedViewSize(size))
            origin.x += size.width + spacing
            lineHeight = max(lineHeight, size.height)
        }
    }
}
```

A custom layout works with `AnyLayout` too, so it can be swapped in for wide
windows only.

## 7. Scenes, multiple windows and the menu bar

- Support multiple windows when people might compare two records
  (`UIApplicationSupportsMultipleScenes`, `WindowGroup(for:)` with a value,
  `openWindow(value:)`). Offer "Open in New Window" in a context menu rather
  than opening windows by default.
- iPadOS 26 adds a menu bar; populate it with `.commands` on the scene, as on
  the Mac, and give frequent commands keyboard shortcuts.
- Use the word "window" in user-facing text, never "scene".
- Do not build custom window frames or controls.

## 8. Test matrix

1. Full screen, portrait and landscape.
2. Windowed at the minimum size, a phone-like width and a wide width; resize
   slowly and watch for jumps.
3. External display.
4. Largest Dynamic Type size in a narrow window.
5. Right-to-left: sidebars, swipe edges and back gestures mirror.

Sources:
[HIG: Windows](https://developer.apple.com/design/human-interface-guidelines/windows),
[HIG: Layout](https://developer.apple.com/design/human-interface-guidelines/layout),
[Adopting Liquid Glass: windows and modals](https://developer.apple.com/documentation/technologyoverviews/adopting-liquid-glass),
[UIRequiresFullScreen](https://developer.apple.com/documentation/bundleresources/information-property-list/uirequiresfullscreen),
[ViewThatFits](https://developer.apple.com/documentation/swiftui/viewthatfits),
[AnyLayout](https://developer.apple.com/documentation/swiftui/anylayout),
[Layout](https://developer.apple.com/documentation/swiftui/layout).
