View Modifiers

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:

character title in a card with shadow

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.


Imagery used in previews by Kaiseto, original images and derived here licensed under Creative Commons 3.0 BY-NC-SA.