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")
}
}
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 }
}
}
}
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?