SwiftUI gives each of our views access to a shared pool of information known as the environment, and we already used it when dismissing sheets. If you recall, it meant creating a property like this:
@Environment(\.presentationMode) var presentationMode
Then when we were ready we could dismiss the sheet like this:
Text("Hello World")
.onTapGesture {
self.presentationMode.wrappedValue.dismiss()
}
This approach allows SwiftUI to make sure the correct state is updated when the view is dismissed – if we attached an @State
property to present the sheet, for example, it would be set back to false when the sheet was dismissed.
This environment is actually packed full of interesting things we can read to help make our apps work better. In this project we’re going to be using the environment to work with Core Data, but here I’m going to show you another important use for it: size classes. Size classes are Apple’s thoroughly vague way of telling us how much space we have for our views.
When I say “thoroughly vague” I mean it: we have only two size classes horizontally and vertically, called “compact” and “regular”. That’s it – that covers all screen sizes from the largest iPad Pro in landscape down to the smallest iPhone in portrait. That doesn’t mean it’s useless – far from it! – just that it only lets us reason about our user interfaces in the broadest terms.
To demonstrate size classes in action, we could create a view that has a property to track the current size class and display it in a text view:
struct ContentView: View {
@Environment(\.horizontalSizeClass) var sizeClass
var body: some View {
if sizeClass == .compact {
return HStack {
Text("Active size class:")
Text("COMPACT")
}
.font(.largeTitle)
} else {
return HStack {
Text("Active size class:")
Text("REGULAR")
}
.font(.largeTitle)
}
}
}
Please try running that in a landscape 12.9-inch iPad Pro simulator so you can get the full effect. At first you should see “REGULAR” displayed, because our app will be given the full screen. But if you swipe upwards gently from the bottom of the simulator screen the dock will appear, and you can drag out something like Safari into the right-hand side of the iPad to enter multi-tasking mode.
Even when our app has only half the screen, you’ll still see our “REGULAR” label appear. But if you drag the splitter to the left – i.e., giving our app only a quarter or so of the available space – now it will change to “COMPACT”.
So, at full screen width we’re in a regular size class, and at half screen width we’re still in a regular size class, but when we go smaller then finally we’re compact. Like I said: it’s broad terms.
Where things get more interesting is if we want to change our layouts depending on the environment. In this situation, it would make more sense to use a VStack
rather than a HStack
when we’re in a compact size class, however this is trickier than you might think.
First, change the code so that we either return a VStack
or a HStack
:
if sizeClass == .compact {
return VStack {
Text("Active size class:")
Text("COMPACT")
}
.font(.largeTitle)
} else {
return HStack {
Text("Active size class:")
Text("REGULAR")
}
.font(.largeTitle)
}
When you build the code you’ll see an ominous error: “Function declares an opaque return type, but the return statements in its body do not have matching underlying types.” That is, the some View
return type of body
requires that one single type is returned from all paths in our code – we can’t sometimes return one view and other times return something else.
You might think you’re going to be clever, and wrap our whole condition inside another view, such as a VStack
, but that doesn’t work either. Instead, we need a more advanced solution called type erasure. I say “advanced” because conceptually it’s very clever and because the implementation of it can be non-trivial, but from our perspective – i.e., actually using it – type erasure is marvelously simple.
First, let’s look at the code – replace your current body
code with this:
if sizeClass == .compact {
return AnyView(VStack {
Text("Active size class:")
Text("COMPACT")
}
.font(.largeTitle))
} else {
return AnyView(HStack {
Text("Active size class:")
Text("REGULAR")
}
.font(.largeTitle))
}
I know that’s quite dense to read, so let me simplify what’s changed:
return AnyView(HStack {
// ...
}
.font(.largeTitle))
If you build the code again you’ll see it compiles cleanly, and even better it looks great when it runs – the app now smoothly switches between a HStack
and a VStack
depending on the size class.
What’s changed is that we wrapped both our stacks in a new view type called AnyView
, which is what’s called a type erased wrapper.
AnyView
conforms to the same View
protocol as Text
, Color
, VStack
, and more, and it also contains inside it a view of a specific type. However, externally AnyView
doesn’t expose what it contains – Swift sees our condition as returning either an AnyView
or an AnyView
, so it’s considered the same type. This is where the name “type erasure” comes from: AnyView
effectively hides – or erases – the type of the views it contains.
Now, the logical conclusion here is to ask why we don’t use AnyView
all the time if it lets us avoid the restrictions of some View
. The answer is simple: performance. When SwiftUI knows exactly what’s in our view hierarchy, it can add and remove small parts trivially as needed, but when we use AnyView
we’re actively denying SwiftUI that information. As a result, it’s likely to have to do significantly more work to keep our user interface updated when regular changes happen, so it’s generally best to avoid AnyView
unless you specifically need it.
SPONSORED From January 26th to 31st you can join a FREE crash course for iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a senior developer!
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.