SwiftUI Adaptive Stack Views
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.
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?
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK