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:
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.
SPONSORED Building and maintaining in-app subscription infrastructure is hard. Luckily there's a better way. With RevenueCat, you can implement subscriptions for your app in hours, not months, so you can get back to building your app.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.