Secondary Views in Practice

A secondary view, be it background or overlay, can be any view. We know from flexible frames that we can create views of fixed sizes, sizes based on their children, or sizes based on their parent. And we saw above that the proposed size of a secondary view is the fixed size of a parent.

So let’s put all this together, and build something cool! A hit points bar for our character that shows how much damage they’ve taken.

We ideally want the size of the hit points bar to be flexible to our needs, as we’ll use it in a few different places. For the character list, something like the following code would be ideal:

struct ContentView : View {
    var body: some View {
        HStack {
            Image("rogue")
            VStack(alignment: .leading) {
                Text("Hasty River")
                    .font(.title)
                HitPointBar(hitPoints: 60, damageTaken: 27)
                    .font(.caption)
                    .frame(width: 200)
            }
        }
    }
}

First let’s start by having some text say how many hit points they have left, we’ll want this somewhere in the bar, so it’s a good point to start:

struct HitPointBar : View {
    var hitPoints: Int
    var damageTaken: Int

    var body: some View {
        Text("\(hitPoints-damageTaken)/\(hitPoints)")
    }
}

That’s actually already enough to get started, the Text has a fixed size and the HitPointBar view is layout-neutral, so the .frame we supply above sets the width. While the Text has no font size specified of its own, it inherits it from the environment so we can set it on the HitPointBar; the .frame is layout-neutral in height, so inherits the height from the caption-sized Text:

hit point bar with just text

I added the border to the view to verify the frame, but that’s already not bad. However the .border was on the .frame outside the view, if we look inside we see that the Text has its usual fixed size and is just positioned within it.

We still need the HitPointBar itself to fill this. In flexible frames I introduced infinite frames as frames that fill their parent, so we can use one of those:

struct HitPointBar : View {
    var hitPoints: Int
    var damageTaken: Int

    var body: some View {
        Text("\(hitPoints-damageTaken)/\(hitPoints)")
            .frame(minWidth: 0, maxWidth: .infinity)
    }
}

We make the frame of the text have the size of the parent in width (which we then fix in the ContentView), while still allow it to be layout neutral in height.

The result looks the same:

hit point bar with text and frame

But this time I’m able to place the .border around the .frame inside the HitPointBar. The text is positioned within that frame, and this frame can be the foundation of the rest of the view.

Once you learn to rely on the fixed sizes of views, and layout-neutral behavior of combinations of views, it’s actually easy to create flexible custom views by using the layout system rather than fighting it.

Okay so let’s add a secondary view to make the bar. Nothing says damage and hit points like a red lozenge:

struct HitPointBar : View {
    var hitPoints: Int
    var damageTaken: Int

    var body: some View {
        Text("\(hitPoints-damageTaken)/\(hitPoints)")
            .foregroundColor(Color.white)
            .frame(minWidth: 0, maxWidth: .infinity)
            .background(Color.red)
            .cornerRadius(8)
    }
}

Pay attention to the ordering of things, and remember that .frame creates a new view around the Text inside it. We deliberately attach the .background secondary view to this frame, which is taking its width from its parent and its height from its children views.

We then apply a .cornerRadius to the combination of the frame and secondary view, which encases them both in a clipping view that masks the boundaries. This means it’ll apply to the Text, background color, and anything else we added.

We also set the foreground color of the Text to white for better contrast.

hit point bar with text and background

Looking good, but we want that hit point bar to be filled with green if they’ve taken no damage, filled green from the left and red from the right according to how many hit points they have left.

That wouldn’t be too hard if the size of the view was fixed, but we’ve deliberately decided to make it flexible and up to the parent. Worse, we’ve decided that the height is going to be dictated by dynamic type, so is flexible as well.

There’s a tool for this, the geometry reader, and while it always expands to fill its entire parent, when we use it as a secondary view, the parent is the view it’s attached to.

For clarity I like to separate out complex secondary views into a separate property, so we first rewrite our control to do this:

struct HitPointBar : View {
    var hitPoints: Int
    var damageTaken: Int

    var body: some View {
        Text("\(hitPoints-damageTaken)/\(hitPoints)")
            .foregroundColor(Color.white)
            .frame(minWidth: 0, maxWidth: .infinity)
            .background(background)
            .cornerRadius(8)
    }

    var background: some View {
        Color.red
    }
}

Now we know we’re going to want two things in this geometry view, a green color from the left for the hit points remaining, and a red color from the right for the damage taken.

There’s a few different ways to achieve that, and they’re all equally valid. For this example we’ll have the red color fill the entire view, and place a green color in front of it, so we’re going to need a z-axis stack for that with a leading alignment.

This all then goes inside the GeometryReader:

var background: some View {
    GeometryReader { g in
        ZStack(alignment: .leading) {
            Rectangle()
                .fill(Color.red)
            Rectangle()
                .fill(Color.green)
                .frame(width: g.size.width
                    * CGFloat(self.hitPoints - self.damageTaken)
                    / CGFloat(self.hitPoints))
            }
        }
    }

In general terms this view looks like something you’ve written before.

The GeometryReader expands to fill the proposed size given by the parent, except this time that proposed size is the size of the frame we put around the text, based on the text size.

A ZStack containing two views with leading alignment is nothing special, the first is a Rectangle filled with red—switching from just using the color for maximum readability, and the second is also a Rectangle just filled with the green instead.

The second Rectangle is constrained in size by placing it inside a frame, layout neutral in height, but fixed in width to that derived from the percentage of hit points remaining and the width returned by the GeometryReader.

The result is the flexible hit point bar we wanted:

hit point bar with text and progress

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