Detecting Device Orientation vs Adaptive Layout in SwiftUI


Greetings, traveler!

There are several ways to detect whether a SwiftUI interface is currently wider than it is tall.

You can read UIDevice.current.orientation. You can compare the width and height reported by GeometryReader. You can also use size classes as a signal for different interface variants.

All of these approaches can be useful. The problem appears when orientation becomes the main input for layout.

In many cases, the interface does not actually care whether the device is in portrait or landscape. It cares whether there is enough room for a specific arrangement.

That leads to a better question:

What layout fits in the space available to this view?

Detecting Orientation

The most obvious option is UIDevice.current.orientation:

let orientation = UIDevice.current.orientation

You can then check:

orientation.isLandscape

This value describes the physical orientation of the device. It can also report values such as:

.faceUp
.faceDown
.unknown

That makes it a poor source of truth for layout.

For UI decisions, the shape of the current container is usually more useful than the physical orientation of the hardware.

A better check is:

GeometryReader { proxy in
    let isLandscape = proxy.size.width > proxy.size.height

    if isLandscape {
        LandscapeView()
    } else {
        PortraitView()
    }
}

This does not tell you that the device is physically in landscape. It tells you that the current container is wider than it is tall.

That distinction matters.

If this is exactly the information you need, the approach is valid. But in most adaptive layouts, even this check is unnecessary.

Orientation Is Often the Wrong Question

Consider this:

if isLandscape {
    HStack {
        DetailsView()
        ActionsView()
    }
} else {
    VStack {
        DetailsView()
        ActionsView()
    }
}

The actual requirement is probably not:

Use HStack in landscape.

It is:

Use HStack when there is enough horizontal space.

Those are different conditions.

The same applies to device checks:

if UIDevice.current.userInterfaceIdiom == .pad {
    SidebarLayout()
} else {
    PhoneLayout()
}

This assumes that iPad means wide and iPhone means narrow.

A view should usually adapt to the space its parent gives it, not to the hardware model running the app.

That makes the layout more reusable and avoids assumptions about screen size, multitasking, windows, sheets, or nested containers.

Start With SwiftUI’s Regular Layout System

Many adaptive layouts do not need geometry at all.

SwiftUI already negotiates sizes between parents and children, so ordinary stacks, grids, flexible frames, alignment, and layout priorities often provide enough flexibility.

For example:

HStack {
    Text("Title")

    Spacer()

    Button("Edit") {
        // ...
    }
}

Or:

Text(content)
    .frame(maxWidth: .infinity, alignment: .leading)

A grid can adapt automatically:

LazyVGrid(
    columns: [
        GridItem(.adaptive(minimum: 160))
    ]
) {
    ForEach(items) { item in
        CardView(item: item)
    }
}

The number of columns follows the available width, so no orientation or device check is needed.

When the layout system can express the requirement directly, that is usually the most robust solution.

Use ViewThatFits When You Have Alternative Layouts

ViewThatFits is useful when the same content has more than one valid arrangement.

For example:

ViewThatFits(in: .horizontal) {
    HStack(spacing: 12) {
        controls
    }

    VStack(alignment: .leading, spacing: 12) {
        controls
    }
}

SwiftUI tries the first view and uses it if it fits in the proposed space. Otherwise, it tries the next one.

The requirement here is clear:

Prefer the horizontal layout when it fits.

There is no need to invent a breakpoint such as:

if proxy.size.width > 600 {
    HStack {
        controls
    }
} else {
    VStack {
        controls
    }
}

A hardcoded threshold may still be appropriate in some designs, but ViewThatFits is often better when the decision depends on the actual content.

This also makes the layout more resilient to longer text, localization, and larger text sizes.

Use Size Classes for Broad Layout Modes

Size classes are useful for larger structural changes:

@Environment(\.horizontalSizeClass)
private var horizontalSizeClass

Then:

if horizontalSizeClass == .regular {
    ExpandedLayout()
} else {
    CompactLayout()
}

This works well for decisions such as:

  • single-column versus multi-column presentation,
  • compact versus expanded controls,
  • sidebar-style navigation versus a simpler stacked layout.

Size classes do not describe orientation or device type.

A .regular horizontal size class does not mean “iPad”, and .compact does not mean “iPhone”. They are coarse descriptions of the space available to the interface.

Use them for semantic layout modes, not for exact measurement.

If you only need to know whether a particular set of controls fits in one row, ViewThatFits is usually a better fit.

Use AnyLayout When the Arrangement Changes

Sometimes the views stay the same but their container changes.

For example:

let layout = horizontalSizeClass == .regular
    ? AnyLayout(HStackLayout(spacing: 16))
    : AnyLayout(VStackLayout(spacing: 16))

layout {
    ProfileView()
    StatisticsView()
    ActionsView()
}

AnyLayout lets you switch between layout containers while preserving the identity of the child views.

It does not decide when the switch should happen. That decision still needs a meaningful input such as:

  • a size class,
  • Dynamic Type,
  • application state,
  • another layout-specific condition.

The advantage is that the layout changes without duplicating the child hierarchy.

Use the Layout Protocol for Custom Placement Rules

When a component has real placement logic, the Layout protocol is usually a better abstraction than measuring a parent and manually updating state.

A custom layout can inspect the space proposed by its parent, measure its subviews, and place them accordingly.

This is useful for components such as:

  • wrapping tags,
  • adaptive toolbars,
  • flow layouts,
  • custom card arrangements,
  • layouts with non-standard alignment rules.

For example:

struct AdaptiveLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // Measure subviews and calculate the required size.
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        // Place subviews inside the available bounds.
    }
}

The important difference is architectural: the layout participates directly in SwiftUI’s layout negotiation instead of measuring the result afterward and feeding it back through state.

For a simple UI, this would be unnecessary. For a reusable component with custom placement behavior, it is often the right tool.

Use GeometryReader When Exact Geometry Matters

GeometryReader is useful when numeric dimensions are part of the design.

Typical examples include:

  • charts,
  • drawing,
  • proportional positioning,
  • geometry-based animations,
  • custom visual effects,
  • local measurements.

For example:

GeometryReader { proxy in
    Circle()
        .frame(
            width: proxy.size.width * 0.5,
            height: proxy.size.width * 0.5
        )
}

Here the exact width is relevant, so measuring it makes sense.

The important rule is to measure as locally as possible.

Avoid measuring the entire screen or root view just so that nested components can calculate their own layout from a global number.

A component should usually respond to its own container.

Avoid Turning Geometry Into Application State

A common implementation looks like this:

@State private var isLandscape = false

Then geometry updates that state:

.onChange(of: proxy.size) { _, newSize in
    isLandscape = newSize.width > newSize.height
}

And the layout depends on isLandscape.

This works, but isLandscape is not really application state. It is a value derived from the current geometry.

That creates an extra cycle:

layout
→ measurement
→ state update
→ body update
→ layout

If the value is only needed locally, derive it locally:

GeometryReader { proxy in
    let isWide = proxy.size.width > proxy.size.height

    content(isWide)
}

Better yet, avoid the measurement if ViewThatFits, an adaptive grid, or a custom Layout can express the same requirement directly.

Use Breakpoints for the Interface, Not for Hardware

A fixed breakpoint can be perfectly reasonable:

if width >= 700 {
    ExpandedView()
} else {
    CompactView()
}

The number should represent a design decision.

It should not exist because one particular device happens to have that width.

Avoid checks like:

if width == 393 {
    // specific iPhone layout
}

or:

if width > 430 {
    // probably iPad
}

Those are device assumptions disguised as layout logic.

A good breakpoint describes the interface.

A bad breakpoint describes hardware.

Adaptive Layout Also Depends on Accessibility

Width is not the only thing that can change how much space a layout needs.

Dynamic Type, localization, and content length can all affect whether an arrangement still works.

For example:

@Environment(\.dynamicTypeSize)
private var dynamicTypeSize

You may decide to switch from a horizontal to a vertical arrangement for accessibility text sizes:

let layout = dynamicTypeSize.isAccessibilitySize
    ? AnyLayout(VStackLayout(spacing: 12))
    : AnyLayout(HStackLayout(spacing: 12))

layout {
    Label("Notifications", systemImage: "bell")
    Toggle("", isOn: $enabled)
}

The container did not change orientation.

The content requirements changed.

This is another reason to base layout decisions on actual constraints instead of treating orientation as the main signal.

Prefer System Containers for Navigation

Adaptive layout also applies to navigation and structural UI.

Use system containers where possible:

NavigationStack {
    ContentView()
}

or:

NavigationSplitView {
    SidebarView()
} detail: {
    DetailView()
}

The same applies to TabView and ToolbarItem.

These APIs carry semantic information that the system can use when adapting the interface.

A custom view pinned to a specific edge only knows the position you gave it.

Custom UI still makes sense when the custom behavior is part of the product. For standard navigation and structural elements, system containers are usually a better foundation.

Do Not Ask the Screen for the Size of a View

One of the most common layout mistakes is still:

let width = UIScreen.main.bounds.width

followed by calculations such as:

let cardWidth = (width - 48) / 2

The screen width and the available width are not necessarily the same thing.

A view can live inside:

  • a split view,
  • a window,
  • a sheet,
  • a sidebar,
  • another custom container.

If a component needs its available width, measure or infer the space available to that component.

Do not use the physical screen as a proxy.

Choosing the Right Tool

A useful rule of thumb is:

RequirementTool
Content should naturally stretch or reflowstacks, grids, flexible frames
Choose the first layout that fitsViewThatFits
Switch between broad compact and expanded modessize classes
Change stack type while preserving child identityAnyLayout
Implement custom placement rulesLayout
Read exact local dimensionsGeometryReader
Know whether a container is wider than it is tallcompare width and height

The last case is valid, but it is much narrower than “build a responsive UI.”

When Comparing Width and Height Is Still Correct

There are cases where the aspect ratio of the current container really is the information you need.

Then this is perfectly reasonable:

GeometryReader { proxy in
    let isLandscapeContainer =
        proxy.size.width > proxy.size.height

    CustomCanvas(
        isLandscape: isLandscapeContainer
    )
}

A name such as isLandscapeContainer or isWide is more precise than deviceOrientation, because it describes what is actually being measured.

This should remain a local layout decision rather than global device state.

Conclusion

There is nothing wrong with checking whether a SwiftUI container is wider than it is tall when its aspect ratio is relevant.

The problem is using that value as the foundation of adaptive layout.

In most cases, the better question is not:

Is this landscape?

It is:

What fits in the space I’ve been given?