Flo Writes Code
← Back to Blog

Beyond VStack & HStack: Custom Layouts in SwiftUI

Learn how to build flexible, reusable layouts with Layout and ViewThatFits instead of reaching for Spacer().

SwiftUI gives us VStack, HStack, and ZStack out of the box—but when you need more control, the layout system has a lot more to offer. Here’s a quick example using ViewThatFits to switch between horizontal and vertical layouts based on available space.

ViewThatFits: Let the system choose

Instead of hard-coding a layout, you can offer two (or more) options and let SwiftUI pick the first one that fits:

struct AdaptiveLabel: View {
    let icon: String
    let title: String

    var body: some View {
        ViewThatFits(in: .horizontal) {
            HStack(spacing: 8) {
                Image(systemName: icon)
                Text(title)
            }
            VStack(alignment: .leading, spacing: 4) {
                Image(systemName: icon)
                Text(title)
            }
        }
    }
}

In narrow widths (e.g. compact size classes or small windows), the view will use the VStack; when there’s enough horizontal space, it uses the HStack. No Spacer() or GeometryReader required.

Example of an adaptive layout in SwiftUI
You can add screenshots or diagrams in the post’s images/ folder and reference them here.

Custom Layout with the Layout protocol

For full control, implement the Layout protocol (iOS 16+). You provide sizeThatFits and placeSubviews:

struct MyCustomLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // Compute and return the layout size
        subviews.reduce(CGSize.zero) { size, subview in
            let subviewSize = subview.sizeThatFits(.unspecified)
            return CGSize(
                width: max(size.width, subviewSize.width),
                height: size.height + subviewSize.height
            )
        }
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        var y = bounds.minY
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            subview.place(at: CGPoint(x: bounds.minX, y: y), proposal: .unspecified)
            y += size.height
        }
    }
}

Once you have a custom layout, use it like any other layout container: MyCustomLayout() { ... }. This is a great way to build reusable layout components that adapt to your design system.

If you want to go deeper, I cover these ideas (and how to avoid Spacer()) in my video Stop Using Spacer() in SwiftUI. Happy layout hacking!