SwiftUI Adaptive Stack Views

How do you adapt your SwiftUI layouts for varying dynamic type size and available horizontal space?

Adapting to Horizontal Size

The layout technique I use the most is switching a horizontal layout to vertical when moving between regular and compact size classes. I find it’s often a good first step when building a layout that works on both iPad (regular width) and iPhone (compact width).

In UIKit, I would do that by switching the axis of a UIStackView. In SwiftUI, we can switch between a HStack and VStack based on the horizontalSizeClass that we get from the @Environment:

struct CompactStack<Content>: View where Content: View {
  @Environment(\.horizontalSizeClass) var horizontalSizeClass
  let content: Content

  init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  var body: some View {
    if horizontalSizeClass == .compact {
      VStack { content }
    } else {
      HStack { content }
    }
  }
}

I might use that like this:

CompactStack {
  Text("01:00:05:12")
  Button(role: .destructive) { ...
  } label: {
    Label("Reset", systemImage: "clock.arrow.circlepath")
  }
}

Regular layout is horizontal, compact layout is vertical

Adapting to Dynamic Type

Another common situation I have is adapting to dynamic type sizes. For example, I might create an AccessibleStack that switches from horizontal to vertical for accessible sizes. Instead of testing for the horizontal size class I now test for the dynamic type size:

struct AccessibleStack<Content>: View where Content: View {
    @Environment(\.dynamicTypeSize) var dynamicTypeSize
    let content: Content

    init(@ViewBuilder content: () -> Content) {
      self.content = content()
    }

    var body: some View {
      if dynamicTypeSize.isAccessibilitySize {
        VStack { content }
      } else {
        HStack { content }
      }
    }
}

Vertical layout with accessible extra large text

Extracting the Condition

A full layout might need several nested stack views embedded in a scroll view each with a different condition. Sometimes I might want to switch when the dynamic type size is at least .extraExtraExtraLarge. Sometimes I want combinations of conditions such as when the type size is accessible and the horizontal size class is compact or I want to switch when the view width drops below a threshold.

I don’t want to create a custom stack view for every situation so I’ve extracted the condition out:

public struct AdaptiveStack<Content: View>: View {
  @Environment(\.horizontalSizeClass) var horizontalSizeClass
  @Environment(\.dynamicTypeSize) var dynamicTypeSize
        
  public typealias ConditionHandler =
    (UserInterfaceSizeClass?, DynamicTypeSize) -> Bool
  private let condition: ConditionHandler
  private let content: Content

  init(condition: @escaping ConditionHandler,
       @ViewBuilder content: () -> Content) {
    self.condition = condition
    self.content = content()
  }

  public var body: some View {
    if condition(horizontalSizeClass, dynamicTypeSize) {
      VStack { content }
    } else {
      HStack { content }
    }
  }
}

The stack view calls the condition handler passing the horizontal size class and dynamic type size. If the handler returns true we use a vertical stack, else we’re horizontal.

For example, here’s how I would create a stack that switches from horizontal to vertical when the horizontal size class is .compact and the text size is .xxxLarge:

struct ContentView: View {
  private func compactXXXLarge(horizontalSizeClass: UserInterfaceSizeClass?,
    dynamicTypeSize: DynamicTypeSize) -> Bool {
    horizontalSizeClass == .compact &&
    dynamicTypeSize >= .xxxLarge
  }

  var body: some View {
    AdaptiveStack(condition: compactXXXLarge) {
      Text("01:00:05:12")
      Button(role: .destructive) {
      } label: {
        Label("Reset", systemImage: "clock.arrow.circlepath")
      }
    }
  }
}

Common Conditions

I don’t want to create those condition handler methods for every view. Instead for the commonly used cases we can create them as static methods of the stack view. For example:

static private func compact(horizontalSizeClass: UserInterfaceSizeClass?, dynamicTypeSize: DynamicTypeSize) -> Bool {
  horizontalSizeClass == .compact
}

static private func regular(horizontalSizeClass: UserInterfaceSizeClass?, dynamicTypeSize: DynamicTypeSize) -> Bool {
  horizontalSizeClass == .regular
}

static private func accessible(horizontalSizeClass: UserInterfaceSizeClass?, dynamicTypeSize: DynamicTypeSize) -> Bool {
  dynamicTypeSize.isAccessibilitySize
}

static private func compactAccessible(horizontalSizeClass: UserInterfaceSizeClass?, dynamicTypeSize: DynamicTypeSize) -> Bool {
  horizontalSizeClass == .compact &&
  dynamicTypeSize.isAccessibilitySize
}

static private func regularAccessible(horizontalSizeClass: UserInterfaceSizeClass?, dynamicTypeSize: DynamicTypeSize) -> Bool {
  horizontalSizeClass == .regular &&
  dynamicTypeSize.isAccessibilitySize
}

For convenience I have an enum case for each condition and a private method that returns the static method for each case:

public enum Condition {
  case compact
  case regular
  case accessible
  case compactAccessible
  case regularAccessible
}

static private func handler(_ condition: Condition) -> ConditionHandler {
  switch condition {
  case .compact: return compact
  case .regular: return regular
  case .accessible: return accessible
  case .compactAccessible: return compactAccessible
  case .regularAccessible: return regularAccessible
  }
}

This provides a convenient initializer for these common situations. The user provides the condition case, we can then look up the static condition method and call the full initializer:

public init(condition: Condition,
            @ViewBuilder content: @escaping () -> Content) {
  self.init(horizontalAlignment: horizontalAlignment,
            condition: AdaptiveStack.handler(condition),
            content: content)
}

So when we want a stack that adapts for the compact horizontal size class:

AdaptiveStack(condition: .compact) {
  ...
}

Adapting to Width

Our condition logic isn’t restricted to horizontal size class and dynamic type size. For example, to switch based on the width of a view:

private struct PanelView: View {
  let width: CGFloat
  private func narrow(horizontalSizeClass: UserInterfaceSizeClass?,
    dynamicTypeSize: DynamicTypeSize) -> Bool {
    width < 700
  }

  var body: some View {
    AdaptiveStack(condition: narrow) {
      Text("Line 1")
      Text("Line 2")
    }
  }
}

This view switches to vertical when the width is less than 700 points. We get the width by wrapping the view in a geometry reader in the parent view:

GeometryReader { proxy in
  PanelView(width: proxy.size.width)
  .frame(maxWidth: .infinity)
}

Adding Alignment and Spacing

For brevity, I’ve left out setting the stack view alignment and spacing. We can add those as parameters with the same defaults as the built-in stack views:

public struct AdaptiveStack<Content: View>: View {
  @Environment(\.horizontalSizeClass) var horizontalSizeClass
  @Environment(\.dynamicTypeSize) var dynamicTypeSize

  public typealias ConditionHandler = (UserInterfaceSizeClass?,
    DynamicTypeSize) -> Bool

  private let horizontalAlignment: HorizontalAlignment
  private let horizontalSpacing: CGFloat?
  private let verticalAlignment: VerticalAlignment
  private let verticalSpacing: CGFloat?
  private let condition: ConditionHandler
  private let content: Content

  public init(horizontalAlignment: HorizontalAlignment = .center,
              horizontalSpacing: CGFloat? = nil,
              verticalAlignment: VerticalAlignment = .center,
              verticalSpacing: CGFloat? = nil,
              condition: @escaping ConditionHandler,
              @ViewBuilder content: () -> Content) {
    self.horizontalAlignment = horizontalAlignment
    self.horizontalSpacing = horizontalSpacing
    self.verticalAlignment = verticalAlignment
    self.verticalSpacing = verticalSpacing
    self.condition = condition
    self.content = content()
  }  

Then in the body we give each stack the right alignment and spacing:

  public var body: some View {
    if condition(horizontalSizeClass, dynamicTypeSize) {
      VStack(alignment: horizontalAlignment, 
        spacing: verticalSpacing) { content }
    } else {
      HStack(alignment: verticalAlignment,
        spacing: horizontalSpacing) { content }
    }
  }
}

Limitations

Nesting different combinations of these adaptive stack views gives me a lot of flexibility but it doesn’t cover every situation. For example, if I want the order of views to change when switching to a vertical layout I need to add those conditional views manually.

How Do You Do It?

It’s still a work in progress, but I’ve ended up with what I consider to be a reasonable compromise between flexibility and ease of use. How are you building adaptive layouts with SwiftUI?