BLACK FRIDAY: Save 50% on all books and bundles! >>

Changing a view’s layout in response to size classes

Paul Hudson    @twostraws   

SwiftUI gives us two environment values to monitor the current size class of our app, which in practice means we can show one layout when space is restricted and another when space is plentiful.

For example, in our current layout we’re displaying the resort details and snow details in a HStack, like this:

HStack {
    Spacer()
    ResortDetailsView(resort: resort)
    SkiDetailsView(resort: resort)
    Spacer()
}

Each of those subviews are internally using a VStack, so we end up with a two by two grid: two rows, with two views in each row. This looks great when space is restricted, but when we have more space it would look better to have them all in one row.

To make this happen we could create copies of ResortDetailsView and SkiDetailsView that handle the alternative layout, but a much smarter solution is to have both those views be layout neutral – to have them automatically adapt to being placed in a HStack or VStack depending on the parent that places them.

First, add this new @Environment property to ResortView:

@Environment(\.horizontalSizeClass) var sizeClass

That will tell us whether we have a regular or compact size class. Very roughly:

  • All iPhones in portrait have compact width and regular height.
  • Most iPhones in landscape have compact width and compact height.
  • Large iPhones (Plus-sized and Max devices) in landscape have regular width and compact height.
  • All iPads in both orientations have regular width and regular height.

Things get a little more complex for iPad when it comes to split view mode, which is when you have two apps running side by side – iOS will automatically downgrade our app to a compact size class at various points depending on the exact iPad model.

Fortunately, all we care about are these two horizontal options: do we have lots of space (regular) or is space restricted (compact). If space is low we’re going to keep the current nested VStack approach so that we don’t try and squeeze everything onto one line, but if there’s more space we’ll ditch that and place the views directly into the parent HStack.

So, find the HStack that contains ResortDetailsView and SkiDetailsView and replace it with this:

HStack {
    if sizeClass == .compact {
        Spacer()
        VStack { ResortDetailsView(resort: resort) }
        VStack { SkiDetailsView(resort: resort) }
        Spacer()
    } else {
        ResortDetailsView(resort: resort)
        Spacer()
        SkiDetailsView(resort: resort)
    }
}
.font(.headline)
.foregroundColor(.secondary)
.padding(.top)

As you can see, that moves the VStack work up to the parent view, rather than keeping it inside ResortDetailsView and SkiDetailsView.

This hasn’t really changed much, and in fact things have gotten a little worse because if you run in an iPhone 11 Pro Max in landscape (regular size class) the two child views are spaced oddly because we went from two spacers down to one.

Fixing that problem is easy, but it creates other problems at the same time. Fortunately, those are easy to fix as well, so stick with me – we’ll get there!

To make our two child views layout neutral – to make them have no specific layout direction of their own, but instead be directed by their parent – we need to make them use Group rather than VStack.

So, update SkiDetailsView to this:

var body: some View {
    Group {
        Text("Elevation: \(resort.elevation)m")
        Text("Snow: \(resort.snowDepth)cm")
    }
}

And update ResortDetailsView to this:

var body: some View {
    Group {
        Text("Size: \(size)")
        Text("Price: \(price)")
    }
}

On an iPhone in portrait mode these look identical, because we’ve gone from having a VStack nested inside another VStack to having a Group nested inside a VStack – there’s no layout difference. But in landscape things are looking a little better because all four text views are now being laid out in a single line. They are laid out badly on a single line, but at least they are on a single line!

The next step is to add some spacers into our two child views, to make sure they put space between their text views.

So, update SkiDetailsView to this:

var body: some View {
    Group {
        Text("Elevation: \(resort.elevation)m")
        Spacer()
        Text("Snow: \(resort.snowDepth)cm")
    }
}

And update ResortDetailsView to this:

var body: some View {
    Group {
        Text("Size: \(size)")
        Spacer()
        Text("Price: \(price)")
    }
}

If you run the app again you’ll see things have gotten both better and worse at the same time: better in landscape because all four pieces of text are spaced neatly across the view, but worse in portrait because those new spacers are causing havoc in our vertical stacks – we have Size and Elevation at the top, then a large gap, then Price and Snow below.

To fix this problem we need to tell the spacers we only want them to work in landscape mode – they shouldn’t try to add space vertically. So, modify the spacers inside ResortDetailView and SkiDetailsView to have zero height, like this:

Spacer().frame(height: 0)

Once again this is a step forward combined with a step backward: our vertical spacing has now disappeared as intended, but now the two child views don’t have space between them – the spacer we placed between them is now tiny.

This happens because using Spacer().frame(height: 0) creates a frame that has a flexible width, causing the child views to take up all available space, which in turn means there’s nothing left for the spacer we placed between those two child views.

So, we need to give that outer spacer a flexible width too – any frame at all is fine, because it will result in the same flexible frame. Try this, for example:

ResortDetailsView(resort: resort)
Spacer().frame(height: 0)
SkiDetailsView(resort: resort)

Now we’re almost there: the layout looks good in portrait, and in landscape the four pieces of text are spaced evenly. However, you might notice the elevation wraps across two lines even though there’s a lot of space free. This is another place where I think SwiftUI is at fault, because I think text should always have a higher layout priority than a spacer – hopefully this will get fixed in a future SwiftUI update.

In the meantime, if this problem affects you then what we need to do is tell SwiftUI that our text is more important than our spacers. This can be done by adding the layoutPriority(1) modifier to each of our four text views.

So, the result of all these changes is that SkiDetailsView should look like this:

var body: some View {
    Group {
        Text("Elevation: \(resort.elevation)m").layoutPriority(1)
        Spacer().frame(height: 0)
        Text("Snow: \(resort.snowDepth)cm").layoutPriority(1)
    }
}

ResortDetailsView should look like this:

var body: some View {
    Group {
        Text("Size: \(size)").layoutPriority(1)
        Spacer().frame(height: 0)
        Text("Price: \(price)").layoutPriority(1)
    }
}

And the HStack in ResortView should look like this:

HStack {
    if sizeClass == .compact {
        Spacer()
        VStack { ResortDetailsView(resort: resort) }
        VStack { SkiDetailsView(resort: resort) }
        Spacer()
    } else {
        ResortDetailsView(resort: resort)
        Spacer().frame(height: 0)
        SkiDetailsView(resort: resort)
    }
}
.font(.headline)
.foregroundColor(.secondary)
.padding(.top)

Now finally our layout should look great in both orientations: one single line of text in a regular size class, and two rows of vertical stacks in a compact size class. It took a little work, but we got there in the end!

Our solution didn’t result in code duplication, which is a huge win, but it also left our two child views in a better place – they are now there just to serve up their content without specifying a layout. So, parent views can dynamically switch between HStack and VStack whenever they want, and SwiftUI will take care of the layout for us. The only rules we did encode are ones that make sense: our text is important, and should be even increased priority when it comes to layout.

Save 50% in my Black Friday sale.

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift (Vapor Edition) Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Server-Side Swift (Kitura Edition) Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.3/5

Link copied to your pasteboard.