Articles, podcasts and news about Swift development, by John Sundell.

Switching between SwiftUI’s HStack and VStack

Published on 08 Jul 2022
Discover page available: SwiftUI

SwiftUI’s various stacks are some of the framework’s most fundamental layout tools, and enable us to define groups of views that are aligned either horizontally, vertically, or stacked in terms of depth.

When it comes to the horizontal and vertical variants (HStack and VStack), we might sometimes end up in a situation where we want to dynamically switch between the two. For example, let’s say that we’re building an app that contains the following LoginActionsView, which lets the user pick from a list of actions when logging in:

struct LoginActionsView: View {
    ...

    var body: some View {
        VStack {
            Button("Login") { ... }
            Button("Reset password") { ... }
            Button("Create account") { ... }
        }
        .buttonStyle(ActionButtonStyle())
    }
}

struct ActionButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .fixedSize()
            .frame(maxWidth: .infinity)
            .padding()
            .foregroundColor(.white)
            .background(Color.blue)
            .cornerRadius(10)
    }
}

Above, we’re using the fixedSize modifier to prevent our button labels from getting truncated, which is something that we should only do if we’re sure that a given view’s content won’t ever be larger than the view itself. To learn more, check out part three of my guide to SwiftUI’s layout system.

Currently, our buttons are stacked vertically, and fill all of the available space on the horizontal axis (you can use the above code sample’s PREVIEW button to see what that looks like). While that looks great on iPhones that are in portrait orientation, let’s say that we instead wanted to use a horizontal stack when our UI is rendered in landscape mode.

GeometryReader to the rescue?

One way to do that would be to use a GeometryReader to measure the currently available space, and based on whether the width of that space is larger than its height, we render our content using either an HStack or a VStack.

While we could definitely place that logic right within our LoginActionsView itself, chances are quite high that we’ll want to reuse that code at some point in the future, so let’s instead create a dedicated view that’ll perform our dynamic stack-switching logic as a stand-alone component.

To make our code even more future-proof, we won’t hard-code what alignment or spacing that our two stack variants will use. Instead, let’s do what SwiftUI itself does, and parametrize those attributes while also assigning the same default values that the framework uses — like this:

struct DynamicStack<Content: View>: View {
    var horizontalAlignment = HorizontalAlignment.center
var verticalAlignment = VerticalAlignment.center
var spacing: CGFloat?
    @ViewBuilder var content: () -> Content

    var body: some View {
        GeometryReader { proxy in
            Group {
                if proxy.size.width > proxy.size.height {
                    HStack(
                        alignment: verticalAlignment,
                        spacing: spacing,
                        content: content
                    )
                } else {
                    VStack(
                        alignment: horizontalAlignment,
                        spacing: spacing,
                        content: content
                    )
                }
            }
        }
    }
}

Since we made our new DynamicStack use the same kind of API that HStack and VStack use, we can now simply swap out our previous VStack for an instance of our new, custom stack within our LoginActionsView:

struct LoginActionsView: View {
    ...

    var body: some View {
        DynamicStack {
            Button("Login") { ... }
            Button("Reset password") { ... }
            Button("Create account") { ... }
        }
        .buttonStyle(ActionButtonStyle())
    }
}

Neat! However, like the above code sample’s PREVIEW shows, using a GeometeryReader to perform our dynamic stack switching does come with a quite significant downside, in that geometry readers always fill all of the available space on both the horizontal and vertical axis (in order to actually be able to measure that space). In our case, that means that our LoginActionsView will no longer just stretch out horizontally, but it’ll now also move to the top of the screen.

While there are various ways that we could address those problems (for example by using a technique similar to the one we used to make multiple views have the same width or height in this Q&A article), the question is really whether measuring the available space is really a good approach when it comes to determining the orientation of our dynamic stacks.

A case for size classes

Instead, let’s use Apple’s size class system to decide whether our DynamicStack should use an HStack or a VStack under the hood. The benefit of doing that is not just that we’ll be able to retain the same compact layout that we had before introducing a GeometryReader into the mix, but also that our DynamicStack will start behaving in a way that’s very similar to how built-in system components behave across all devices and orientations.

To start observing the current horizontal size class, all we have to do is to use SwiftUI’s environment system — by declaring an @Environment-marked property (with the horizontalSizeClass key path) within our DynamicStack, which will then let us switch on the current sizeClass value within our view’s body:

struct DynamicStack<Content: View>: View {
    ...
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        switch sizeClass {
        case .regular:
            hStack
        case .compact, .none:
            vStack
        @unknown default:
            vStack
        }
    }
}

private extension DynamicStack {
    var hStack: some View {
        HStack(
            alignment: verticalAlignment,
            spacing: spacing,
            content: content
        )
    }

    var vStack: some View {
        VStack(
            alignment: horizontalAlignment,
            spacing: spacing,
            content: content
        )
    }
}

With the above in place, our LoginActionsView will now dynamically switch between having a horizontal layout when rendered using the regular size class (for example in landscape on larger iPhones, or in either orientation when running full-screen on iPad), and a vertical layout when any other size class configuration is used. All while still using a compact vertical layout that doesn’t use any more space than what’s needed to render its content.

Using the Layout protocol

Although we’ve already ended up with a neat solution that works across all iOS versions that support SwiftUI, let’s also explore a few new layout tools that are being introduced in iOS 16 (which at the time of writing is still in beta as part of Xcode 14).

One such tool is the new Layout protocol, which both enables us to build completely custom layouts that can be integrated directly into SwiftUI’s own layout system (more on that in a future article), while also providing us with a new way to dynamically switch between various layouts in a very smooth, full animatable way.

That’s because it turns out that Layout is not just an API for us third-party developers, but Apple have also made SwiftUI’s own layout containers use that new protocol as well. So, rather than using HStack and VStack directly as container views, we can instead use them as Layout-conforming instances that are wrapped using the AnyLayout type — like this:

private extension DynamicStack {
    var currentLayout: AnyLayout {
        switch sizeClass {
        case .regular, .none:
            return horizontalLayout
        case .compact:
            return verticalLayout
        @unknown default:
            return verticalLayout
        }
    }

    var horizontalLayout: AnyLayout {
        AnyLayout(HStack(
            alignment: verticalAlignment,
            spacing: spacing
        ))
    }

    var verticalLayout: AnyLayout {
        AnyLayout(VStack(
            alignment: horizontalAlignment,
            spacing: spacing
        ))
    }
}

The above works since both HStack and VStack directly conform to the new Layout protocol when their Content type is EmptyView (which is the case when we don’t pass any content closure to such a stack), as we can see if we take a peak at SwiftUI’s public interface:

extension VStack: Layout where Content == EmptyView {
    ...
}

Note that, due to a regression, the above conditional conformance was omitted from Xcode 14 beta 3. According to Matt Ricketson from the SwiftUI team, a temporary workaround would be to instead use the underlying _HStackLayout and _VStackLayout types directly. Hopefully that regression will be fixed in future betas.

Now that we’re able to resolve what layout to use through our new currentLayout property, we can now update our body implementation to simply call the AnyLayout that’s returned from that property as if it was a function — like this:

struct DynamicStack<Content: View>: View {
    ...

    var body: some View {
        currentLayout(content)
    }
}

The reason that we can apply our layout by calling it as a function (even though it’s actually a struct) is because the Layout protocol uses Swift’s “call as function” feature.

So what’s the difference between our previous solution and the above, Layout-based one? The key difference (besides the fact that the latter requires iOS 16) is that switching layouts preserves the identity of the underlying views that are being rendered, which isn’t the case when swapping between an HStack and a VStack. The result of that is that animations will be much smoother, for example when switching device orientations, and we’re also likely to get a small performance boost when performing such changes as well (since SwiftUI always performs best when its view hierarchies are as static as possible).

Picking the view that fits

But we’re not quite done yet, because iOS 16 also gives us another interesting new layout tool that could potentially be used to implement our DynamicStack — which is a new view type called ViewThatFits. Like its name implies, that new container will pick the view that best fits within the current context, based on a list of candidates that we pass when initializing it.

In our case, that means that we could pass it both an HStack and a VStack, and it’ll automatically switch between them on our behalf:

struct DynamicStack<Content: View>: View {
    ...

    var body: some View {
        ViewThatFits {
            HStack(
                alignment: verticalAlignment,
                spacing: spacing,
                content: content
            )

            VStack(
                alignment: horizontalAlignment,
                spacing: spacing,
                content: content
            )
        }
    }
}

Note that it’s important that we place the HStack first in this case, since the VStack will likely always fit, even within contexts where we want our layout to be horizontal (such as in full-screen mode on iPad). It’s also important to point out that the above ViewThatFits-based technique will always attempt to use our HStack, even when rendered with the compact size class, and will only pick our VStack-based layout when the HStack doesn’t fit.

Conclusion

So that’s four different ways to implement a dedicated DynamicStack view that dynamically switches between an HStack and a VStack depending on the current context. I hope you enjoyed this article, and if you have any questions, comments, or feedback, then feel free to reach out via either Twitter or email.

Thanks for reading!