5

SwiftUI Adaptive Stack Views

 2 years ago
source link: https://useyourloaf.com/blog/swiftui-adaptive-stack-views/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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?


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK