We’ve spent some time looking at views without really diving into what a view is, and considering what other options are available to us. Let’s look at that now.
View
is a defined as a protocol:
protocol View {
associatedtype Body : View
var body: Self.Body { get }
}
This means that to conform to View
we must provide a single property
called body
whose value is any type that also conforms to View
.
SwiftUI views are defined in terms of other views, composed together into complex layouts.
At the foundation of these layouts are fundamental views such as Image
and Text
that are defined only in terms of themselves. They conform to
View
, but have Never
as their associated Body
type.
When we define our own views we rarely, if ever, need to worry about the
actual types involved, which is fortunate because they can be very
complex. We can use some View
and allow the compiler to infer our true
Body
type.
While we can use some View
as the type of a computed property, we
cannot use it as the type of a stored property, which means creating a
re-usable view containing another is difficult with View
alone.
For example, we might have a common “card” look that we want to use for several views:
We know the code to create the character title:
struct ContentView : View {
var body: some View {
HStack {
Image("brawler")
Text("Sir Bunnington")
.font(.title)
}
}
}
And we know in principle the code to turn that into a card:
struct CardView : View {
// 🛑 This is not legal syntax.
var content: some View
var body: some View {
content
.padding()
.background(Color.white)
.cornerRadius(8)
.shadow(radius: 4)
}
}
But as noted in the comment, for reasons we discussed above, this will not compile.
We could figure out the true type of the content
, but that would mean
our CardView
only worked with that exact type—an HStack
containing
an Image
and a Text
with a modified font. It wouldn’t be flexible
for any view, and we need that for our project.
If you’re thinking about reaching for generics, you’re on the right lines, and we’ll look at an approach using those in view builders. But SwiftUI already also provides exactly what we need right now, and we’ve been using them all along without really looking at what we were doing.
ViewModifier
is another protocol provided alongside View
:
protocol ViewModifier {
typealias Content
associatedtype Body : View
func body(content: Self.Content) -> Self.Body
}
The primary difference between a ViewModifier
and a View
is that to
conform we don’t just provide a property of view type, but a function
that receives a view of one type (Content
) and returns a new view of
another type (Body
).
With a small adjustment, our preview attempt at a CardView
can be
turned instead into a Card
view modifier:
struct Card : ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color.white)
.cornerRadius(8)
.shadow(radius: 4)
}
}
We could use this directly, but just as we did with custom alignments it’s worth spending the extra few lines of code to properly integrate it.
To do that we define an extension to View
that applies our new Card
as a modifier to the view it’s called on:
extension View {
func card() -> some View {
modifier(Card())
}
}
Now to use this, all we need to do is add .card
just like we do for
any other modifier:
struct ContentView: View {
var body: some View {
HStack {
Image("brawler")
Text("Sir Bunnington")
.font(.title)
}
.card()
}
}
Environment
For the battle tracker, it turns out that having the hit points unaligned gives a poor user experience, and we want a way to use the specified font’s monospaced digit alternative if available.
Because the actual view is low down the hierarchy, but the decision about which font to use is high up, this is the kind of scenario in which environment it useful.
Fortunately view modifiers participate in the lifecycle just as views
do, so @Environment
and similar work inside a custom ViewModifier
just as they do inside a custom View
.
One approach we might take would be:
struct MonospacedDigit : ViewModifier {
@Environment(\.font) var font: Font?
func body(content: Content) -> some View {
return content
.environment(\.font, font?.monospacedDigit())
}
}
extension View {
func monospacedDigit() -> some View {
modifier(MonospacedDigit())
}
}
This kind of view modifier is quite common, it takes a property from the environment, modifiers it in some way, and then places the modified result back into the environment for its content.
Note that in this example we use .environment(\.font, …)
to update the
font, rather than .font
directly, because it’s possible that there is
no font in the current example and that modifier can’t accept nil
. It
would have been just as valid to have picked a default font, it depends
on the needs of your project.