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)
            }
        }
    }
}

Getting Started

Our ideal code has the vertical size of the hit point bar being determined by a font size, and the horizontal size being as wide as possible, while allowing a frame to constrain it.

Since the size of the font is key, we’ll start by having a Text view with a label saying how many hit points the character has left:

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

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

That’s actually already enough to get started. Contrary to my usual examples I’ve explicitly added a yellow border to the text so that we can see what’s happening. We’ll also add a green border to the point we use the HotPointBar:

HitPointBar(hitPoints: 60, damageTaken: 27)
    .font(.caption)
    .frame(width: 200)
    .border(Color.green)

I recommend using modifiers like .border and .background to debug your custom views, they can be hugely insightful.

hit point bar with just text

The Text has no font size specified of its own, so will inherit it from the environment, meaning the .font applied to the HitPointBar itself will be used.

As we saw in flexible frames HitPointBar is a layout-neutral view, so will tightly wrap the Text within it; and since we didn’t specify a height for the .frame, the frame is layout-neutral in height as well.

Thus the height of the HitPointBar is exactly the height of the text in the given font size, which is exactly what we want.

The width though is not yet correct, since the Text only has the width necessary for its contents, and HitPointBar tightly wraps that, it’s only the .frame in the parent that is the full width, and that’s in the wrong place to be useful.

We still need the HitPointBar itself to fill this frame.

In flexible frames I introduced infinite frames as frames that fill their parent, so we can use one of those (and drop the border from the parent call site):

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

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

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)")
            .frame(minWidth: 0, maxWidth: .infinity)
            .foregroundColor(Color.white)
            .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 view, 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)")
            .frame(minWidth: 0, maxWidth: .infinity)
            .foregroundColor(Color.white)
            .background(HitPointBackground(hitPoints: hitPoints,
                                           damageTaken: damageTaken))
            .cornerRadius(8)
    }
}

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

    var body: 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:

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

    var body: 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 almost certainly 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.