Font-sized Images

Often you need to include images inline in text, while being mindful of supporting all of the various user-customizable sizes of text, not to mention the accessibility sizes:

An image matched to each possible title font size

One way to do this is by creating a custom SF Symbol, but that involves individually drawing or hinting each possible size. This obviously gives the optimal result, but it’s not necessarily something we have the resource to do. In the case of user-supplied imagery such as a character portrait, it’s not even an option available to us.

There’s a nice trick to achieving this with any resizable image, and it builds on what we learned in views choose their own sizes, and secondary views.

Recall that a secondary view receives as its proposed size the chosen size of the view it’s attached to.

So what we need to do is attach the resizable Image as a secondary view to a Text view that will inherit the font-size from the environment and choose its size accordingly.

We can then remove the Text itself from the output using the .hidden modifier, while leaving the Image overlaid on top visible:

struct ContentView : View {
    @ObservedObject var character: Character

    var body: some View {
        HStack {
            Text("M")
                .hidden()
                .overlay(
                    Image(character.imageName)
                        .resizable()
                        .aspectRatio(contentMode: .fit))
            Text(character.name)
        }
        .font(.title)
    }
}

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

View Builders

In the last post we looked at view modifiers as a way of building custom UI components that use other views as a form of content. In this one we’re going to look at a different approach using ViewBuilder.

We’re actually going to build the exact same card structure we built before:

character title in a card with shadow

When we used a ViewModifier this became a .card method we applied to the HStack like .frame or .padding, this time we’re going to make a block construct like HStack itself.

Our goal is that the code to make character card will look like:

struct ContentView : View {
    var body: some View {
        Card {
            HStack {
                Image("brawler")
                Text("Sir Bunnington")
                    .font(.title)
            }
        }
    }
}

To achieve this we’ll need Card to be a View again rather than a modifier, and we’ll use generics to handle all of the possible types of view that the content might be:

struct Card<Content> : View
    where Content : View
{
    var content: Content

    var body: some View {
        content
            .padding()
            .background(Color.white)
            .cornerRadius(8)
            .shadow(radius: 4)
    }
}

This approach in general is useful for views where there is a single view that needs to be passed in to the constructor. Good examples of this being used in SwiftUI include NavigationLink for the destination parameter.

But when we need a more complex child layout we don’t always want to have to abstract it into a custom View structure, and instead want to be able to use a block in at that point.

This is where view builders come in. @ViewBuilder is an attribute that we can declare on the parameters of methods we define, and most usefully, that includes the constructor.

So we can extend our above example to set the value of content from a view builder:

struct Card<Content> : View
    where Content : View
{
    var content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View {
        content
            .padding()
            .background(Color.white)
            .cornerRadius(8)
            .shadow(radius: 4)
    }
}

The builder is a method that takes no arguments and returns a view, which we allow type inference to define as the Content our type is generic over. We set the property of that type by calling the method, which invokes the block passed.

And now we can use the Card view exactly as we intended in our goal code.

Patterns

The decision about whether to use a view, view modifier or a view builder is ultimately going to come down to what makes the most sense for your code.

Some like Button make sense as view builders.

But there are good examples in SwiftUI of view modifiers that instinct might suggest be view builders. .frame is a modifier on a view, there is no Frame builder, even though it’s placing the view inside it.

Only once you start combining multiple frames does it makes sense why it’s a modifier, since it’s easier to combine modifiers than it is to combine builders.

A good rule of thumb for me has to be to use a modifier first, and only use a builder when the code patterns really pulled strongly for that syntax.


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

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.

Size-Limiting Frames

Occasionally we come across a layout where we need to limit the bounds of a view in a particular dimension. For example we might have a view where we show the character portrait along with the title they might be introduced by at court.

This works fine for the brawler:

character portrait with short title

But for the dragoon who has a bit of a khaleesi complex, it gets a little bit unwieldy and we need to limit the number of titles we actually show:

character portrait with long title

One way we can do this is with a .lineLimit on the Text, but when combined with accessibility font sizes, that can still grow taller than we intend. Sometimes we need to constrain a height in terms of pixels.

We might try to use a .frame with maxHeight specified:

struct ContentView : View {
    @ObservedObject var character: Character

    var body: some View {
        // ⚠️ This is an example that does not work.
        HStack {
            Image(character.imageName)
            Text(character.title)
                .frame(maxHeight: 200)
        }
    }
}

This works great for the dragoon’s overly long title, allowing it to flow on as many lines as can fit in the space, and then truncating the rest:

character portrait with truncated title

But when we try it on the brawler, there’s a new problem:

character portrait with short title and too-large frame

We specified a maximum height for the frame intending to limit the height of the text, which it does, but the frame itself has chosen the height we specified as the maximum, rather than the height of the text within it.

If we review flexible frames we can understand why.

When we omit a constraint to .frame, the frame is layout-neutral for that constraint and chooses the size chosen by its child. But when we provide a value the frame is no longer layout-neutral for that constraint, and no longer considers the size chosen by the child.

The frame received a proposed size from its parent, and applied the maximum height constraint, proposing the maximum height to the Text child, which in the dragoon’s case resulted in the truncation of their overly long title.

In both cases the Text chose a size equal or smaller to that height. Since the frame was layout-neutral in terms of minimum height, this set the minimum height of the frame to the size of the child, but the maximum height was supplied by us. The size proposed by the frame’s parent was outside the range of these two heights, and was constrained by the frame: to its maximum height.

This wasn’t what we wanted, we wanted the frame to constrain the size of the child, but still be layout-neutral.

Fortunately there’s a solution for this, and it involves the third set of .frame parameters we didn’t consider yet, the ideal size. Since we didn’t specify any value for .idealHeight then the frame’s ideal height is layout-neutral, that is, the ideal height of the frame is the height of the child.

SwiftUI gives us modifiers that fix the size of a view to its ideal size:

/// Fixes this view at its ideal size.
func fixedSize() -> some View

/// Fixes the view at its ideal size in the specified dimensions.
func fixedSize(horizontal: Bool, vertical: Bool) -> View

As we’re dealing with multi-line text we don’t want the first variant since text always ideally wants to be rendered on just one line, but the second variant is perfect since it will allow us to fix just the height of the frame.

We want to fix the size of the .frame so the modifier goes after it, rather then before—which would fix the size of the Text:

struct ContentView : View {
    @ObservedObject var character: Character

    var body: some View {
        HStack {
            Image(character.imageName)
            Text(character.title)
                .frame(maxHeight: 200)
                .fixedSize(horizontal: false, vertical: true)
        }
    }
}

The dragoon’s long title renders exactly as before, constrained by the maximum height of the frame:

character portrait with truncated title and correct-sized frame

But now the frame around the brawler’s title is fixed in height to the frame’s ideal size, that of the brawler’s title, and does not expand the stack unnecessarily:

character portrait with short title and correct-sized frame

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

Borders

It seems a little odd to write a post about borders this late, since every post so far has already used them without calling them out explicitly. In each of the examples I’ve added borders to the code given to better illustrate the layout.

It’s worth spending a little time looking at them in their own right though, because they’re slightly more interesting than you might expect.

There is a single method for specifying the border for a view:

func border<S>(_ content: S, width: CGFloat = 1) -> some View where S : ShapeStyle

The first parameter is required and specifies a shape style, there’s a quite a few options for that, but fortunately Color confirms to the ShapeStyle protocol so for the simplest cases all we need to do is specify a color.

The second parameter is optional and specifies the width of the border, defaulting to a single pixel.

As with most of SwiftUI, this is intuitive enough that add a single pixel yellow border on Text we would use code like this:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
            .border(Color.yellow)
    }
}

And this produces the same example I’ve used before, except with the border that’s always been in the preview actually stated in code:

text with a border

The reason that borders are really useful for experimenting with or demonstrating layout is that they don’t work like frame or padding; in that they do not add space around the Text to draw the border.

It’s not obvious with a single pixel, but we can demonstrate this by increasing the width of the border:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
            .border(Color.yellow, width: 4)
    }
}

If this worked like padding, the border would increase in width around the text; instead we see that that border overlays it; .border creates a secondary view on its child, and draws the border overlaid on top of it.

text with a thick border

.border creates a secondary view on its child, and draws the border overlaid on top of it

If we wanted the border around the view instead, we can combine it with .padding:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
            .padding(4)
            .border(Color.yellow, width: 4)
    }
}

This creates the Text view, and then .padding creates another view around that with additional padding added, and then .border adds a secondary view to the padding view, and draws overlaid on that:

text with thick border and padding

It’s important to note the distinction that the border is on the padding view; combined effects can be performed by carefully placing the overlays in the correct place:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
            .border(Color.red)
            .padding(4)
            .border(Color.yellow, width: 4)
            .border(Color.red)
    }
}

Here we create a red border overlaid on the Text, and then use padding to draw a thicker yellow border around the Text, and finally overlaid another red border onto the padding:

text with three borders

The total border width is 5px since it includes the additional pixel-wide border overlaid on the Text, or put another way, the yellow part of the border is 3px wide since the outer pixel is overlaid by the red border added to it.

Padding

By now we should be used to the idea that all views in SwiftUI choose their own size, for example a Text view has the size required to render the string provided:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
    }
}

Creates a view with the exact bounds necessary:

text view

We also showed that the .frame modifier actually creates a new view with the dimensions specifies, and positions the Text view within it, such that:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
            .frame(width: 200, height: 200)
    }
}

Actually creates two views, a .frame that is 200×200 in size, and a Text within it with the exact bounds necessary to render its contents:

text inside frame

We looked into this process further in flexible frames, introduced the concept of layout neutral views that choose their own size based on their children, and showed that in either dimension .frame can have a fixed size, be layout neutral, or through minimum and maximum size constraints base its own size on that proposed by its own parent.

We’ll now take a look at another useful modifier view, one that adds padding around its child view, and has a number of different forms that we can use:

func padding(_ length: CGFloat) -> some View
func padding(_ insets: EdgeInsets) -> some View
func padding(_ edges: Edge.Set = .all, _ length: CGFloat? = nil) -> some View

The first form sets the padding of all edges to the length specified.

The second form sets the padding of each of the edges to the specific individual values you specify through the EdgeInsets value.

The third form sets the padding of the set of edges you specify to the length supplied, leaving other edges unpadded. The third form also allows you to specify nil as the length, instead of zero, which instructs SwiftUI to use a system default amount of padding appropriate for the situation.

Default values for all parameters of the third form are provided, which uses the system default padding for all edges, we’ll use that in our example:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
            .padding()
    }
}

The .padding modifier is no different from modifiers like .frame, it doesn’t modify the Text in any way, it instead creates a new view that adds padding, and positions the Text view inside it as a child:

text with padding

The layout process is actually a little more interesting than just adding padding, and is almost but not quite layout neutral. It in fact works something like we see for stacks when considering spacing.

  1. Parent proposes a size to the padding view.
  2. Padding view subtracts the appropriate padding length from each edge.
  3. Padding view proposes this smaller size to its child.
  4. Child chooses its size.
  5. Padding view takes the child’s size, adds the appropriate padding length back to each edge, and chooses that as its own size.
  6. Parent positions the padding view within its bounds.

Thus a .padding view always tightly wraps its child (aside from the padding itself), with both being positioned by the parent frame, but at the same time adds an additional constraint (the padding) to the size the child can be.

We can demonstrate this by placing the .padding inside a frame:

struct ContentView : View {
    var body: some View {
        Text("Nogitsune Takeshi")
            .font(.title)
            .padding()
            .frame(width: 200, height: 200)
    }
}

The .frame has a fixed size of 200×200, the .padding view subtracts the system default padding of 16px (in this case) from each side, and supplies the Text with a proposed size of 168×168.

That’s too small for Text to layout on one line, but still enough room to wrap over two lines, so it returns its size appropriately to do that. .padding adds back the padding before returning its size, and the .frame positions the padding view inside it.

text with padding inside frame

As we can see, the padding view still tightly wraps the Text, it isn’t increased in height or width to try and fill the parent frame, and is centered within it instead.

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 is our goal:

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.

Geometry Reader

For most layout needs we can combine stacks and flexible frames, allowing us to make views and controls put together from fixed size primitives views upwards.

For more complex layout needs, another option is to use GeometryReader. This is a construct that acts like an infinite frame, proposing the size of its parent to its children, and choosing its parent size as its own.

As an added feature, it passes the proposed size received to the builder as a closure argument. For example we can layout an image at a maximum of half of the size of the parent, while maintaining aspect ratio, with:

struct ContentView : View {
    var body: some View {
        GeometryReader { g in
            ZStack {
                Image("barbarian")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(maxWidth: g.size.width / 2,
                           maxHeight: g.size.height / 2)
            }
            .frame(width: g.size.width, height: g.size.height)
        }
    }
}

The GeometryReader acts exactly like the infinite frame we saw in flexible frames, it proposes the size of its parent to its child, but it also passes that proposed size g to our view builder.

And just like the infinite frame, the geometry reader doesn’t use the size of the child when deciding its own size; instead it always returns the proposed size from the parent as its own size.

An important gotcha is that within the view builder we need to do our own sizing, positioning and alignment; ZStack is perfect for this. We still need to position that, so we place it inside a frame that has the same size as the reader parent, and let the stack be centered inside it.

Finally inside the stack we place our image, and place that inside a frame that constrains its width and height to half the size of the reader.

image in geometryreader

In Secondary Views

With access to the proposed size of the parent, GeometryReader can seem powerful, but the resulting fixed size equally that can limit their usefulness. When combined with secondary views they become even more convenient.

As we saw above, when free floating, a geometry reader expands to fill the size proposed by the parent.

But because the proposed parent size of a secondary view is the fixed size decided by the view its attached to, that is the proposed size. Thus GeometryReader inside a secondary view returns the size of the view it’s attached to.

GeometryReader inside a secondary view returns the size of the view it’s attached to

For clarity I like to separate out complex secondary views into a separate view:

struct OverlaidImage : View {
    var body: some View {
        Image("barbarian")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .overlay(OverlayView())
    }
}

struct OverlayView : View {
    var body: some View {
        GeometryReader { g in
            Image("overlay")
                .resizable()
                .frame(width: g.size.width, height: g.size.height / 2)
                .position(y: g.size.height / 2)
        }
    }
}

In this example the Image in the body is allowed to be resizable while maintaining its own aspect ratio, with the ultimate bounds of that determined by whatever uses our OverlaidImage custom view.

We then use an .overlay secondary view to draw another image over the bottom half of that image.

In order to constrain that to the bottom half we need to know the size of the Image we’re drawing over, and the GeometryReader works for this because it’s in the secondary view.

In ZStacks

As we saw in stacks and secondary views, the z-axis stack has a useful property where it’s only the children with the highest layout priority that influence the size of the stack, and those with lower priorities receive that size as a proposed size.

This can be usefully combined with GeometryReader just as we can with a secondary view, and can often produce more readable results.

Consider the above example, reformulated using a ZStack with the GeometryReader given a lower layout priority:

struct OverlaidImage : View {
    var body: some View {
        ZStack {
            GeometryReader { g in
                Image("overlay")
                    .resizable()
                    .frame(width: g.size.width, height: g.size.height / 2)
                    .position(y: g.size.height / 2)
            }
            .layoutPriority(-1)

            Image("barbarian")
                .resizable()
                .aspectRatio(contentMode: .fit)
        }
    }
}

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

Secondary Views

Secondary views are one of the more interesting layout tools available in SwiftUI, to understand them first we have to recall that views have fixed sizes. To recap the process:

  1. Parent proposes a size to its child.
  2. Child decides on its size.
  3. Parent positions the child within its bounds.

Secondary views are useful because of where they fit in to this process, and how they interact with it. To demonstrate, let’s use a simple example:

struct ContentView : View {
    var body: some View {
        Text("Hasty River")
            .font(.title)
            .background(Color.yellow)
    }
}

We create a Text which will have a fixed size of its content, and then we add a secondary view using .background; the value of this is the secondary view added, in this case, a Color.

Color when used as a View simply sets its size to the proposed size received from its parent, fillings its bounds.

text with a yellow background

The result shows us that the proposed size for the secondary view is the size chosen by the view its attached to.

the proposed size for the secondary view is the size chosen by the view its attached to

So we can refine our process a little:

  1. Parent proposes a size to its child.
  2. Child decides on its size.
  3. Child proposes its size to its secondary view(s).
  4. Secondary view decides on its size.
  5. Parent positions the child within its bounds.

In our first experiment we just filled the secondary view with a color, what if we use a view there that’s inflexible about its size, and ends up being larger than the child? Perhaps an Image:

struct ContentView : View {
    var body: some View {
        Text("Hasty River")
            .font(.title)
            .background(Image("rogue"))
    }
}

If we’ve been paying attention we’re almost certainly going to expect that to break out of its bounds, but how does that affect the frame of the child its attached to?

text with background image overflowing bounds

The answer is that it doesn’t, the frame of the Text in green remains unaffected by the frame of the secondary view in red. All that happens is that the child positions its secondary view, even though it overflowed.

So now our process looks like:

  1. Parent proposes a size to its child.
  2. Child decides on its size.
  3. Child proposes its size to its secondary view(s).
  4. Secondary view(s) decides on their size.
  5. Child positions its secondary view(s) within its bounds.
  6. Parent positions the child within its bounds.

To see how this interacts with other views, let’s do a side-experiment using a VStack and some other lines of text:

struct ContentView : View {
    var body: some View {
        VStack {
            Text("My Character")
                .font(.caption)
            Text("Hasty River")
                .font(.title)
                .background(Image("rogue"))
            Text("Rogue")
        }
    }
}

If the secondary view has any part to play in the layout, we would expect to see the vertical stack account for it:

vstack of text with a background image on the middle text

The vertical stack ignored the secondary view completely; indeed everything we’ve learned about stacks should mean this isn’t a surprise.

We saw above that the Text did not change its size to account for the overflowing secondary view, so there was no way for the stack to account for it; after a view positions its secondary views they are otherwise completely removed from the layout process.

after a view positions its secondary views they are otherwise completely removed from the layout process

So a secondary view gives us two things:

  • a view that has a proposed size that is the decided size of the view it is attached to.
  • a view that is otherwise removed from the layout process.

The latter has the most utility in creating background views using .background, or overlay views using .overlay, that might be larger than their parent.

The former though can be extraordinarily useful in custom controls, we’ll look at making one in secondary views in practice.

ZStack as a Secondary View

When we looked at stacks, we covered the basics of the z-axis stack and mentioned that the size of the stack is the union of the bounds of all its children with the highest layout priority.

By using sets of children with different layout priorities, we can replicate the implementation of .background and .overlay within a ZStack.

For example, our initial example with a colored background:

struct ContentView : View {
    var body: some View {
        Text("Hasty River")
            .font(.title)
            .background(Color.yellow)
    }
}

Has an equivalent expression using a z-axis stack:

struct ContentView : View {
    var body: some View {
        ZStack {
            Color.yellow
                .layoutPriority(-1)

            Text("Hasty River")
                .font(.title)
    }
}

The ZStack first processes the Text child since it has the highest layout priority, and then chooses its own size as the same size, since there are no other children with that layout priority.

Next the Color receives as its proposed size the size of the stack, and since it’s completely flexible, occupies all of that space.

Note that the layout priority has no effect on the z-axis ordering of the children, and that the Color is still placed behind the Text.

.overlay can be replicated similarly, with the lower layout priority children being placed after those with a higher priority, so they appear on top.


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

Exploding Stacks

A common layout desire is to place views in the corner of a larger view, or of the device. This is particularly interesting because it’s more instructive to visit the methods that don’t work and explain why, before showing the best way to do it.

Our desired result is as follows:

view with images in each corner

We’ll concentrate first on the problem of putting the dragoon Image in the bottom-right, since once that’s solved, the others are easy.

Having read about flexible frames and infinite frames, stacks, and alignments, our first attempt might be to use an alignment of a z-axis stack, and place that in an infinite frame:

struct ContentView: View {
    var body: some View {
        // ⚠️ This is an example that does not work.
        ZStack(alignment: .bottomTrailing) {
            Image("dragoon")
        }
        .frame(minWidth: 0, maxWidth: .infinity,
               minHeight: 0, maxHeight: .infinity)
    }
}

But that means we skipped views have fixed sizes, where we learned that .frame creates a new view and positions the modified view inside it. And that’s what happens:

view with frame around stack

The Image has a fixed size, and the ZStack has only the minimum size necessary to contain its children. The frame around it has the full size and positions the ZStack inside it, centered because that’s the default of the frame.

So since we can’t use a .frame around the ZStack to change the size of it, what can we do?

We can put the infinite frame inside the ZStack, around the Image:

struct ContentView: View {
    var body: some View {
        // ⚠️ This is an example that does not work.
        ZStack(alignment: .bottomTrailing) {
            Image("dragoon")
                .frame(minWidth: 0, maxWidth: .infinity,
                       minHeight: 0, maxHeight: .infinity)
        }
    }
}

I refer to this trick as exploding the stack, the stack has the minimum size of its children, but its child is the frame around the Image and that causes it to be sized as large as it can be:

view with frame around image inside stack

The ZStack now fills the entire device, and the Image is still fixed in size within it, but we’re still having an issue with the Image being centered rather than aligned.

This might seem like a surprise since the ZStack has a specified alignment of .bottomTrailing, but what we’ve missed here is that the child of stack is now the frame causing it to explode out; the Image is being positioned by the .frame and not the ZStack.

What we need to do is move the alignment to the .frame:

struct ContentView: View {
    var body: some View {
        ZStack {
            Image("dragoon")
                .frame(minWidth: 0, maxWidth: .infinity,
                       minHeight: 0, maxHeight: .infinity,
                       alignment: .bottomTrailing)
        }
    }
}

Since the ZStack has just one child, its alignment isn’t important, in fact the stack is not even necessary at all, but we’ll keep it in since we’re just aligning one view out of four.

view with alignment on frame

This does exactly what we want.

Now we can bring back the other three Image views, using the ZStack to overlay them together, but specifying different alignment for each:

struct ContentView: View {
    var body: some View {
        ZStack {
            Image("brawler")
                .frame(minWidth: 0, maxWidth: .infinity,
                       minHeight: 0, maxHeight: .infinity,
                       alignment: .topLeading)
            Image("rogue")
                .frame(minWidth: 0, maxWidth: .infinity,
                       minHeight: 0, maxHeight: .infinity,
                       alignment: .topTrailing)
            Image("barbarian")
                .frame(minWidth: 0, maxWidth: .infinity,
                       minHeight: 0, maxHeight: .infinity,
                       alignment: .bottomLeading)
            Image("dragoon")
                .frame(minWidth: 0, maxWidth: .infinity,
                       minHeight: 0, maxHeight: .infinity,
                       alignment: .bottomTrailing)
        }
    }
}

Each Image has its fixed size, and each is surrounded by a .frame that explodes out the surrounding ZStack to the proposed size of its own parent. Alignment is specified for each frame individually, to align the Image within it:

view with four images and alignment on frames

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