Stop Treating Size Class As Screen Width In SwiftUI

The old SwiftUI shortcut was understandable:

@Environment(\.horizontalSizeClass) private var horizontalSizeClass

var isWide: Bool {
    horizontalSizeClass == .regular
}

For years, that was good enough in a lot of iPhone code. Compact meant phone-shaped. Regular meant iPad-shaped. You could branch a screen, ship the feature, and move on.

Resizable iPhone apps make that shortcut much easier to break.

Apple’s WWDC26 recap says iOS apps are now resizable so people can use them on larger displays, on iPad, and in iPhone Mirroring on Mac. The What’s new in SwiftUI session shows Xcode 27 Live Previews with resize handles for checking iPhone Mirroring and iPhone-on-iPad behavior. The UIKit-focused Modernize your UIKit app session is even more direct: stop using main-screen bounds, remember that an iPhone app can still run with the phone user-interface idiom in these resized contexts, and use the available size of the surrounding view when you need finer control.

That last sentence is the important SwiftUI lesson too.

Size class is a trait. It is not the width of your screen, your scene, your split column, your sheet, your inspector, or the specific view you are laying out right now.

The earlier post on this site, Make SwiftUI Toolbars Work In Resizable iPhone Apps, focused on command surfaces: priorities, overflow, pinned actions, and navigation bar behavior. This post is about the rest of the screen. Lists, details, search, empty states, filters, editors, and the navigation decisions that hold them together.

One beta note before the code: the examples below use stable SwiftUI layout tools such as GeometryReader, ViewThatFits, NavigationStack, and NavigationSplitView. When I mention WWDC26-era testing hooks or scene-sizing APIs, treat the names as current-seed shaped. Verify exact spelling, availability, and platform behavior against the Xcode 27 SDK installed on your machine.

The branch that lies

Here is the kind of screen that deserves an audit. It is a mailbox-style UI, but the same problem shows up in note apps, CRMs, file browsers, settings screens, photo editors, and dashboards.

import SwiftUI

struct InboxScreen: View {
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    @State private var selectedMessage: Message.ID?
    @State private var query = ""

    var body: some View {
        if horizontalSizeClass == .regular {
            NavigationSplitView {
                FolderList()
            } content: {
                MessageList(
                    selection: $selectedMessage,
                    query: $query
                )
                .searchable(text: $query)
            } detail: {
                if let selectedMessage {
                    MessageDetail(messageID: selectedMessage)
                } else {
                    InboxPlaceholder()
                }
            }
        } else {
            NavigationStack {
                MessageList(
                    selection: $selectedMessage,
                    query: $query
                )
                .searchable(text: $query)
                .navigationDestination(for: Message.ID.self) { messageID in
                    MessageDetail(messageID: messageID)
                }
            }
        }
    }
}

This reads cleanly, but it assumes horizontalSizeClass == .regular means “there is enough room for a split layout.”

That is not the question the environment value answers.

Community discussion after WWDC26, including the Fatbobman and iOS Dev Weekly threads around resizable iPhone apps, has been circling the same practical warning: old size-class assumptions are not reliable enough for whole-screen layout audits. Do not rely on horizontalSizeClass changing at the same moments or with the same granularity as the available window or container width.

Even when size class does change exactly when you expect, it is still too coarse for many layout decisions. A detail column inside a split view can be narrow in a regular size class. A sheet can be wide enough for two panes inside a compact app. A search sidebar might need 280 points to be useful while a media inspector needs 360.

Ask the question directly:

How much room does this component actually have?

Replace categories with thresholds

I usually start with app-specific layout buckets. Not device names. Not “phone” and “tablet.” Buckets based on minimum useful widths.

enum InboxLayoutBucket: Equatable {
    case singleColumn
    case listAndDetail
    case sidebarListAndDetail

    init(width: CGFloat, dynamicTypeSize: DynamicTypeSize) {
        let needsMoreRoom = dynamicTypeSize.isAccessibilitySize

        let listAndDetailWidth: CGFloat = needsMoreRoom ? 760 : 680
        let threeColumnWidth: CGFloat = needsMoreRoom ? 1080 : 960

        switch width {
        case ..<listAndDetailWidth:
            self = .singleColumn
        case ..<threeColumnWidth:
            self = .listAndDetail
        default:
            self = .sidebarListAndDetail
        }
    }
}

Those numbers are not universal. They come from the content.

For this mailbox screen, I would write the thresholds down like this:

  • A message list is still useful around 320 points.
  • A readable detail pane wants at least 360 points before margins.
  • A folder sidebar is useful around 220 to 280 points.
  • Search should not crush list rows below their minimum useful width.
  • Accessibility Dynamic Type needs earlier collapse points because labels wrap sooner.

That gives you a defensible starting point:

single column: list or detail owns the full container
two columns: list around 320, detail gets the rest
three columns: sidebar around 240, list around 360, detail gets the rest

The thresholds belong near the feature because they are product decisions, not platform constants.

Measure the container, not the screen

You do not need a giant root GeometryReader that turns the whole app into a coordinate-space negotiation. Keep geometry close to the component that needs it.

struct AvailableWidth<Content: View>: View {
    @Environment(\.dynamicTypeSize) private var dynamicTypeSize

    var content: (CGFloat, InboxLayoutBucket) -> Content

    var body: some View {
        GeometryReader { proxy in
            let width = proxy.size.width
            let bucket = InboxLayoutBucket(
                width: width,
                dynamicTypeSize: dynamicTypeSize
            )

            content(width, bucket)
                .frame(
                    width: proxy.size.width,
                    height: proxy.size.height,
                    alignment: .topLeading
                )
        }
    }
}

This is intentionally boring. It measures the available size proposed to this view. It does not read UIScreen.main.bounds. It does not assume the scene size is the same thing as the content width. It does not turn size class into a proxy for pixels.

There are valid scene-level measurements. UIKit’s WWDC26 guidance points to UIWindowScene.effectiveGeometry for scene-level information, and scene-level geometry-change notifications can be useful for app-wide policy or caches. Verify the exact delegate method or observer API in your installed SDK. A SwiftUI list/detail component should usually respond to the size SwiftUI actually gave that component.

That distinction matters once your view can appear in more than one place:

  • full screen on iPhone
  • a resizable iPhone Mirroring window on Mac
  • an iPhone app running resizably on iPad
  • a column inside NavigationSplitView
  • a sheet, popover, inspector, or embedded child flow
  • a Mac Catalyst window with a toolbar, sidebar, and title bar taking their own space

The component does not care which environment produced the width. It cares whether its own content can still breathe.

Build the screen from the bucket

Now the screen can branch on layout capacity instead of size class.

struct InboxScreen: View {
    @State private var selectedMessage: Message.ID?
    @State private var query = ""

    var body: some View {
        AvailableWidth { width, bucket in
            switch bucket {
            case .singleColumn:
                CompactInboxFlow(
                    selectedMessage: $selectedMessage,
                    query: $query
                )

            case .listAndDetail:
                ListDetailInboxFlow(
                    selectedMessage: $selectedMessage,
                    query: $query
                )

            case .sidebarListAndDetail:
                ThreeColumnInboxFlow(
                    selectedMessage: $selectedMessage,
                    query: $query,
                    availableWidth: width
                )
            }
        }
    }
}

The compact flow should be a real navigation flow, not a cramped imitation of the wide layout.

struct CompactInboxFlow: View {
    @Binding var selectedMessage: Message.ID?
    @Binding var query: String

    var body: some View {
        NavigationStack {
            MessageList(
                selection: $selectedMessage,
                query: $query
            )
            .searchable(text: $query)
            .navigationTitle("Inbox")
            .navigationDestination(for: Message.ID.self) { messageID in
                MessageDetail(messageID: messageID)
            }
        }
    }
}

This compact example assumes MessageList uses value-based navigation links such as NavigationLink(value: message.id) when the row is tapped. If your list only mutates selectedMessage, push from an explicit NavigationPath instead.

The two-column flow gives list and detail enough room to be useful. In this example, the first NavigationSplitView column is intentionally acting as the primary message-list column. If your feature has a real folder sidebar at this width, use the three-column initializer and keep the semantic columns separate.

struct ListDetailInboxFlow: View {
    @Binding var selectedMessage: Message.ID?
    @Binding var query: String

    var body: some View {
        NavigationSplitView {
            MessageList(
                selection: $selectedMessage,
                query: $query
            )
            .searchable(text: $query)
            .navigationTitle("Inbox")
            .navigationSplitViewColumnWidth(
                min: 320,
                ideal: 360,
                max: 420
            )
        } detail: {
            InboxDetailColumn(selectedMessage: selectedMessage)
        }
    }
}

The three-column flow can use a manual composition when you want precise control over minimums. A NavigationSplitView is often the better default, but a feature-level HStack is reasonable when the middle column, search behavior, and detail pane have specific product requirements.

struct ThreeColumnInboxFlow: View {
    @Binding var selectedMessage: Message.ID?
    @Binding var query: String
    var availableWidth: CGFloat

    private var sidebarWidth: CGFloat {
        min(max(availableWidth * 0.22, 220), 280)
    }

    private var listWidth: CGFloat {
        min(max(availableWidth * 0.34, 340), 440)
    }

    var body: some View {
        HStack(spacing: 0) {
            FolderList()
                .frame(width: sidebarWidth)

            Divider()

            MessageList(
                selection: $selectedMessage,
                query: $query
            )
            .searchable(text: $query)
            .frame(width: listWidth)

            Divider()

            InboxDetailColumn(selectedMessage: selectedMessage)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
}

struct InboxDetailColumn: View {
    var selectedMessage: Message.ID?

    var body: some View {
        if let selectedMessage {
            MessageDetail(messageID: selectedMessage)
        } else {
            InboxPlaceholder()
        }
    }
}

Notice that the navigation decision moved with the layout decision.

In a single column, tapping a message should push detail. In a two-column layout, selection should update the detail pane. In a three-column layout, folders, search results, and detail can stay visible together. That is not just a visual rearrangement. It changes where state lives, how selection behaves, how keyboard focus should move, and what “back” means.

If you only hide and show stacks based on size class, you usually find these bugs later:

  • selecting a row in compact mode mutates state intended for a split view
  • search remains active after the view collapses, leaving the detail unreachable
  • the detail pane appears with no selected object after a resize
  • a popover presentation is still configured for a now-compact width
  • VoiceOver order follows the old visual hierarchy instead of the current one

The bucket is a good place to centralize those decisions.

Keep search honest

Search is one of the first things to expose a fake wide layout.

A search field that works at 390 points does not necessarily work inside a 260-point column beside a detail pane. The field may fit, but the result rows stop carrying enough information. Sender, subject, snippet, date, unread state, attachment count, and actions all start competing.

Make the row adapt locally too. If the row only has two or three useful variants, ViewThatFits is cleaner than measuring every row.

struct MessageRow: View {
    var message: Message

    var body: some View {
        ViewThatFits(in: .horizontal) {
            HStack(spacing: 8) {
                Text(message.sender)
                    .fontWeight(message.isUnread ? .semibold : .regular)

                Text(message.subject)

                Spacer(minLength: 8)

                Text(message.receivedDate, format: .dateTime.hour().minute())
                    .foregroundStyle(.secondary)
            }

            VStack(alignment: .leading, spacing: 2) {
                Text(message.sender)
                    .fontWeight(message.isUnread ? .semibold : .regular)

                Text(message.subject)
                    .foregroundStyle(.secondary)
            }
        }
        .lineLimit(1)
    }
}

ViewThatFits is not a replacement for screen-level architecture. It is good for local choices: one-line versus two-line metadata, a labeled control versus an icon-only control, a horizontal filter strip versus a compact menu.

If an action cluster has the same problem, apply the same idea there:

struct MessageRowActions: View {
    var message: Message

    var body: some View {
        ViewThatFits(in: .horizontal) {
            HStack {
                Button("Archive", systemImage: "archivebox") {
                    archive(message)
                }

                Button("Reply", systemImage: "arrowshape.turn.up.left") {
                    reply(to: message)
                }

                Button("Flag", systemImage: "flag") {
                    flag(message)
                }
            }

            HStack {
                Button("Archive", systemImage: "archivebox") {
                    archive(message)
                }

                Menu("More", systemImage: "ellipsis.circle") {
                    Button("Reply", systemImage: "arrowshape.turn.up.left") {
                        reply(to: message)
                    }

                    Button("Flag", systemImage: "flag") {
                        flag(message)
                    }
                }
            }
        }
    }
}

Dynamic Type changes the breakpoints

If your thresholds do not account for Dynamic Type, they are probably optimistic.

The right answer is rarely “disable the wide layout for all accessibility sizes.” Some wide layouts are better for accessibility because they reduce navigation depth and keep context visible. The point is to raise minimums based on the content that expands.

extension DynamicTypeSize {
    var layoutScale: CGFloat {
        guard self >= .accessibility1 else {
            return 1.0
        }

        switch self {
        case .accessibility1:
            1.12
        case .accessibility2:
            1.20
        case .accessibility3:
            1.30
        default:
            1.40
        }
    }
}

enum EditorLayoutBucket {
    case singleColumn
    case canvasAndInspector

    init(width: CGFloat, dynamicTypeSize: DynamicTypeSize) {
        let inspectorMinimum = 320 * dynamicTypeSize.layoutScale
        let canvasMinimum: CGFloat = 360

        if width >= canvasMinimum + inspectorMinimum {
            self = .canvasAndInspector
        } else {
            self = .singleColumn
        }
    }
}

This is more honest than a single “regular width” branch. The inspector does not become useful because the app crossed a platform-defined trait boundary. It becomes useful when the inspector controls, labels, values, and validation messages have enough room at the user’s current text size.

Also check vertical pressure. A resizable iPhone app can be wide and short. If a detail header, segmented control, search field, and error banner consume half the height, your “wide” layout may still need to collapse secondary controls or move filters into a sheet.

Do not animate every resize

Interactive resizing creates a lot of intermediate sizes. The layout should respond, but your app should not treat every point change as a reason to reload data, recreate stores, or fire analytics events.

Keep bucket changes cheap:

struct ResponsiveInboxRoot: View {
    @State private var selectedMessage: Message.ID?
    @State private var query = ""

    var body: some View {
        AvailableWidth { _, bucket in
            InboxScreenContent(
                bucket: bucket,
                selectedMessage: $selectedMessage,
                query: $query
            )
            .animation(.snappy, value: bucket)
        }
    }
}

Animate the discrete bucket change if it improves continuity. Do not animate every measured width. Avoid this pattern:

.onChange(of: measuredWidth) { _, newWidth in
    viewModel.reloadEverything(for: newWidth)
}

If a current Xcode 27 seed gives you an interactive resize phase callback, use it for work that genuinely depends on the interaction phase: pausing expensive previews, lowering render quality during the drag, or invalidating a cache after the resize completes. Do not use it as a substitute for normal SwiftUI layout.

Minimum sizes are product decisions

Some screens should collapse early. Some should declare a minimum useful size. A spreadsheet, timeline editor, camera control surface, or drawing canvas may become actively harmful below a certain width.

For SwiftUI-first code, prefer designing the compact alternative instead of fighting the window:

struct ReportBuilder: View {
    @Environment(\.dynamicTypeSize) private var dynamicTypeSize

    var body: some View {
        GeometryReader { proxy in
            let bucket = EditorLayoutBucket(
                width: proxy.size.width,
                dynamicTypeSize: dynamicTypeSize
            )

            switch bucket {
            case .singleColumn:
                ReportBuilderCompact()

            case .canvasAndInspector:
                HStack(spacing: 0) {
                    ReportCanvas()
                        .frame(minWidth: 360)

                    Divider()

                    ReportInspector()
                        .frame(minWidth: 320, idealWidth: 360)
                }
            }
        }
    }
}

Scene-level restrictions still have a place, especially for UIKit-heavy apps, games, or document windows with hard minimums. During the beta cycle, verify APIs such as UISceneSizeRestrictions against your installed SDK before relying on exact names or semantics. Even then, treat restrictions as the outer guardrail. The app still needs good behavior near the minimum.

What to audit

Search for the obvious patterns first:

horizontalSizeClass == .regular
horizontalSizeClass == .compact
UIDevice.current.userInterfaceIdiom
UIScreen.main.bounds
UIApplication.shared.windows
interfaceOrientation
GeometryReader

The goal is not to delete every size class check. Size classes are still useful for broad adaptation and for matching system behavior. A sidebar may become available in regular width. A presentation may choose a different default style.

The bug is treating size class as an exact measurement.

When you find a branch, classify it:

  • Trait decision: “Use the system’s regular-width sidebar behavior.”
  • Component capacity decision: “Show three columns only when this container has at least 960 points.”
  • Content minimum decision: “Keep the inspector hidden unless it can have 320 points at this Dynamic Type size.”
  • Navigation decision: “Push detail in one column, select detail in two or more columns.”
  • Scene policy decision: “This game supports discrete resizing and has a minimum render size.”

Only the first one belongs to size class alone.

Testing checklist

Use Xcode 27 Live Previews resize handles for fast iteration, but do not stop there. The preview is a layout lab, not a complete device matrix.

Check these cases before calling a screen adaptive:

  • Resize the preview slowly across each threshold and watch selection, search focus, sheets, popovers, and empty states.
  • Test the narrowest useful width, not just the default iPhone width.
  • Test a wide-but-short window.
  • Test Accessibility Large, Accessibility Extra Extra Extra Large, and Bold Text if your screen is text-heavy.
  • Run the app through Device Hub’s resize mode when available in your installed Xcode seed.
  • Test real iPhone Mirroring on macOS 27 because input, focus, and pointer behavior are part of the experience.
  • Test the iPhone app on iPad, including multitasking and external-display paths if your app supports them.
  • Rotate only as a bounds-change test. Do not use interface orientation as the layout source of truth.
  • Verify VoiceOver order after each bucket change.
  • Capture at least one screenshot per bucket for regression tests or manual release notes.

If you support older OS versions, make the responsive layout use APIs available there and gate only the WWDC26-specific testing or scene-policy pieces. The geometry-first idea does not require iOS 27.

The rule

Use size class when you want to follow a system trait.

Use container geometry when you need to know how much room a view has.

Use content-derived thresholds when you decide whether a layout is actually useful.

That combination survives resizable iPhone apps better than horizontalSizeClass == .regular ever did, and it gives you a cleaner audit path: every branch in the layout can explain what space it needs and why.

References