Flexible Frames

In my first post I showed that in SwiftUI views choose their own sizes, derived from the size of their content, but that that they can be flexible about what size they choose based on the size proposed by their parent.

In the post about stacks we showed that the size chosen by a stack is derived from the sizes of its children, and reinforced why it’s important not to confuse the proposed size a child receives from its parent with the size later chosen by its parent.

The layout process looks like:

  1. Parent proposes a size to its child.
  2. Child chooses its own size.
  3. Parent chooses its own size based on the child, and its own constraints.
  4. Parent positions the child within its bounds.

I also showed that while you cannot override the chosen size of a view, you can use this process to influence the result by using views such as frames.

A frame is added to a view using the .frame view modifier; it creates a new view which both proposes the size given to its children, and chooses the size given as its own size, finally positioning the child view it was added to within its own bounds.

It’s important to remember that the modifier does not directly change the bounds of the view being modified in anyway. We can demonstrate this using inflexible views such as Image; firstly by placing one in a frame that’s too big:

struct ContentView : View {
    var body: some View {
        Image("barbarian")
            .frame(width: 200, height: 200)
    }
}

Which we should recall shows that while the frame proposes a larger size to its child, the child still chooses its size based on its source image, and all that’s left to do for the frame is position it:

image in too large frame

And likewise if we place the Image in a frame that’s too small:

struct ContentView : View {
    var body: some View {
        Image("barbarian")
            .frame(width: 100, height: 200)
    }
}

The Image‘s chosen size is not overridden by the frame, and exceeds its bounds:

image in too small frame

This latter has a particularly interesting behavior; since the parent of the frame is only aware of the size chosen by the frame, from the point of view of its siblings and all other views, the size of the Image is irrelevant for layout purposes.

Layout Neutral Views

So far in all our frame examples we’ve passed a fixed value to the width and height parameters, but if you take a look at the API you’d see that these are both optional.

func frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View

What happens if we omit one? For example, the height:

struct ContentView : View {
    var body: some View {
        Image("barbarian")
            .resizable()
            .frame(width: 50)
    }
}

Note first that we made our Image resizable, which we should recall has it ignore the size of its source content, and instead choose the size proposed by the parent.

We’ve placed the Image inside a .frame which will be its parent, and we’ve proposed a width for that parent, but we haven’t proposed a height:

image in frame with width only

What we see is that the image has the width we specified, taking the proposed width from its parent frame, but is as high as there is space available in the device, using the height proposed by the frame’s own parent.

Likewise we see that the frame for the image has that same height.

When a view simply passes the proposed size it receives from its own parent down to its children without modification, and then chooses as its own size the size that its own child chose, we say that the view is layout neutral. Simply: when a view is layout neutral, the size of the view is the same as the size of its child.

when a view is layout neutral, the size of the view is the same as the size of its child

We’ve been using layout neutral views all the time probably without realizing it.

The Image returned in our examples is the child of its frame, which is made to be the content of a view by returning it as the value of the computed ContentView.body property. But ContentView itself is also a view, and is the parent to its body, and child to its parent, the window.

We’ve never had to concern ourselves with this because ContentView is layout neutral, so invisible to layout.

So our frame is fixed in width, but layout neutral in height.

Unsurprisingly it works the other way too, when a frame is fixed in height but layout neutral in width:

struct ContentView : View {
    var body: some View {
        Image("barbarian")
            .resizable()
            .frame(height: 50)
    }
}

The Image becomes as wide as is proposed by the frame’s parent to the frame:

Flexible Frames

Now that we’ve more fully explored what we can do with the .frame modifier that lets us specify fixed widths and heights, it’s time to look at the other .frame modifier available to us:

func frame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center) -> some View

There are a lot of options here, allowing us to constrain the width and height in three ways:

  • minimum length
  • ideal length
  • maximum length

When any option is nil the behavior is to be layout neutral for that characteristic, while remaining constrained by the others you provide.

when any option is nil the behavior is to be layout neutral for that characteristic

In order to fully understand these we have to remember that the frame is a view in its own right, and only influences the size of its child view through the size it proposes to that child, and is itself influenced only by the size proposed by its own parent.

We can expand the layout process to include all three views:

  1. Parent proposes a size to the frame.
  2. Frame applies constraints to that size.
  3. Frame proposes the constrained size to the child.
  4. Child chooses its own size.
  5. Frame chooses its own size size based on the child, and its own constraints.
  6. Frame positions the child within its bounds.
  7. Parent positions the frame within its bounds.

We’ll show three frames in our examples, with the gray border being a parent frame supplied equivalent to the device in our other examples, and we’ll concentrate on the width parameters to show behavior, with the height being understood as behaving equivalently.

Minimum Length

The minimum length characteristic first comes into play when the frame applies constraints to the size proposed by its parent. The child receives a proposed length that is the larger of the parent’s length, and the frame’s minimum length.

This is easiest to demonstrate when the parent is too small:

struct ContentView : View {
    var body: some View {
        // parent frame width is 50
        Image("barbarian")
            .resizable()
            .frame(minWidth: 100)
    }
}

The frame receives a proposed width of 50 from the parent, but has a minimum width constraint of 100; thus the child image receives a proposed width of 100 from the frame.

Both the image and the frame overflow the parent:

frame with minWidth larger than parent

The minimum length also applies to the frame choosing it’s own size based on that of the child. If the child chooses a length that is smaller than the frame’s minimum length, the frame’s length becomes its minimum length and the child is positioned inside it.

struct ContentView : View {
    var body: some View {
        // image width is 101
        Image("barbarian")
            .frame(minWidth: 200)
    }
}

The frame receives a proposed width from the parent, and applies the minimum width constraint of 200; which the child receives as its proposed size.

The child image is not resizable, so chooses a width of 101; the frame still applies its own minimum width, so chooses 200 and centers the image within that space:

frame with minWidth smaller than child

Maximum Length

Like with the minimum length, the maximum length characteristic applies both to the size proposed by the parent, and the frame’s choice as to its own size after the child has chosen.

For the maximum length, the child receives a proposed length that is the smaller of the parent’s length and the frame’s maximum length.

This is easiest to demonstrate when the parent is too large:

struct ContentView : View {
    var body: some View {
        // parent frame width is 200
        Image("barbarian")
            .resizable()
            .frame(maxWidth: 100)
    }
}

The frame receives a proposed width of 200 from the parent, but has a maximum width constraint of 100; thus the child image receives a proposed width of 100 from the frame.

The parent centers the resulting frame within its bounds:

frame with maxWidth larger than child

The maximum length also applies to the frame choosing its own size, based on that of the child. If the child chooses a length that is larger than the frame’s maximum length, the frame’s length is still constrained to its maximum length, and the child overflows it:

struct ContentView : View {
    var body: some View {
        // image width is 101
        Image("barbarian")
            .frame(maxWidth: 50)
    }
}

The frame receives a proposed width from the parent, and applies the maximum width constraint of 50; which the child receives as its proposed size.

The child image is not resizable, so chooses a width of 101; the frame still applies its own maximum width, so chooses 50 for its own width, and the image overflows:

frame width maxWidth smaller than child

Ideal Length

The ideal length is used within situations where the parent is unable to propose a size to the frame, for example within a ScrollView, specifying the length to choose in that situation.

Image has ideal lengths of its source image, even when resizable, Text has ideal lengths based on its string content rendered on a single line.

Setting ideal lengths tends to be necessary when creating flexibly-sized shapes and other custom views, for example by using geometry reader, that are going to be used inside ScrollView.

Interaction with Stacks

In the post about stacks we said we’d revisit how stacks lay out their children, and now we have enough information to understand what happens when laying out a mix of children.

Recall our example where we placed an Image and two Text views inside a horizontal stack that was constrained by a parent frame and a line limit:

struct ContentView : View {
    var body: some View {
        HStack {
            Image("barbarian")

            Text("Nogistune")
                .font(.title)
            Text("Takeshi")
                .font(.title)       
        }
        .lineLimit(1)
        .frame(width: 300, height: 100)
    }
}

The constraints caused the space to be divided equally between the two flexible Text views while the inflexible Image view was unaffected:

horizontal stack of three views

The stack receives a proposed size from its parent, and as we saw it first subtracts the spacing it places between its children.

Now that we know that views have a minimum and maximum size we can understand what happens next.

First the minimum size of each view is subtracted and allocated to each view, since the Image is not resizable, this is the same size as its source image. This effectively pre-allocates the full size the image needs and leaves the remainder available to the other views.

Next each view in turn is offered an equal portion of the remainder as a proposed size, dividing it equally amongst them.

However since views such as Text have a maximum size that tightly wraps their content, they do not necessarily need to consume all of that equal part of the remainder. Should they choose a smaller size, whatever is left is added back to the remainder, and increases the size proposed to each further child.

When a stack is placed within a view such as a ScrollView where there is no proposed length available in one or more dimensions, the ideal length in that dimension of each child is instead used.

Layout Non-Neutral Frames

So far we’ve only needed to specify one of the minimum or maximum lengths individually, the default behavior of being layout neutral usually sufficing for the unspecified length.

We can specify a minimum and maximum constraint together as long as they are in ascending order, this not only constrains the frame size within the given bounds, but also stops the frame from being layout neutral in that dimension.

Put another way, setting both a minimum and maximum length for a dimension bases the frame size on its parent, not its child.

setting both a minimum and maximum length for a dimension bases the frame size on its parent, not its child

For example we can constrain a frame to between the size of its parent and the size of its non-resizable child:

struct ContentView : View {
    var body: some View {
        // image width is 101, parent frame width is 200
        Image("barbarian")
            .frame(minWidth: 50, maxWidth: 150)
    }
}

In this case since the proposed width from the frame’s parent is 200, and that does not fall in the range of 50–150, the child receives a proposed width of 150 instead. Since the child image is not resizable it chooses a width of 101.

This is where things now differ; if we had specified nil for either the minimum or maximum width, the frame would be layout neutral and simply choose its size as equal to the size of the child, since that’s within the bounds of the constraint.

But by providing both, the frame is no longer layout neutral, and instead chooses its size based on the proposed size it received from its own parent. Since the proposed width was 200, and the maximum width of the frame is 150, the frame’s width chosen to be 150, and the child is positioned inside that:

frame with minWidth and maxWidth

As we saw in earlier examples, had we specified both constraints separately, the frame’s width would have been the child’s of 101.

We can also see what happens if the range of the constraints include the size of the parent frame:

struct ContentView : View {
    var body: some View {
        // image width is 101, parent frame width is 200
        Image("barbarian")
            .frame(minWidth: 50, maxWidth: 250)
    }
}

In this case the parent width of 200 falls within the range 50–250, so the child receives a proposed width of 200. Again the child image is not resizable, so it chooses a width of 101.

Since both a minimum and maximum width are specified, the frame is not layout neutral, and chooses its width based on the proposed width from its own parent. As that’s within its constraints, it chooses 200 and positions the child inside that:

frame with minWidth and maxWidth expanding to parent

We should round out by considering the case where the parent length is smaller than the minimum length of the frame:

struct ContentView : View {
    var body: some View {
        // image width is 101, parent frame width is 200
        Image("barbarian")
            .frame(minWidth: 225, maxWidth: 500)
    }
}

All rules learned thus far are observed.

The proposed size to the child becomes the frame’s minimum width and the child chooses a smaller size. Since the frame is not layout neutral it chooses its minimum width as its own size, since that is closest to that of the proposed width from its parent.

Within its chosen width, it positions its child, and overflows its parent’s bounds:

frame with minWidth and maxWidth overflowing parent

Infinite Frames

Since specifying both the minimum and maximum length makes the frame sized based on the parent, constrained by the range given, an interesting case occurs when you pass 0 as the minimum length and .infinity as the maximum length:

struct ContentView : View {
    var body: some View {
        // image width is 101, parent frame width is 200
        Image("barbarian")
            .frame(minWidth: 0, maxWidth: .infinity)
    }
}

We can work through this as we have other examples.

The parent proposes a width of 200 to the frame, which is within the frame’s constraints of 0–∞, so the frame proposes a width of 200 to the child. The child image is still not resizable, so chooses 101 as its width.

Because both a minimum and maximum width are specified, the frame is not layout neutral. Since the proposed width from the parent of 200 is greater than 0 and less than ∞, the frame chooses its own width to be 200 and it positions the child inside that:

infinite frame

In other words, when specified with a minimum width of 0, and a maximum width of .infinity, the frame has the size of its parent.

when specified with a minimum width of 0, and a maximum width of .infinity, the frame has the size of its parent

Both are required, because if we’d specified only the minimum or maximum width, the frame would have been layout neutral for the other, and chosen the size of its child rather the proposed size from its parent.

Infinite frames can be a convenient way to adjust the alignment of a view within the bounds of a parent of unknown alignment, for example:

struct ContentView : View {
    var body: some View {
        // always layout top-leading, in any frame
        Image("barbarian")
            .frame(minWidth: 0, maxWidth: .infinity,
                   minHeight: 0, maxHeight: .infinity,
                   alignment: .topLeading)
    }
}

Ordinarily as a construct this would be unnecessary since you could simply change the alignment of the surrounding frame, but it’s useful example of how the infinite frame works.

In both width and height the proposed length from the parent falls within the 0–∞ bounds, so the child of the frame receives the proposed size from the frame’s parent as a proposed size.

Since both minimum and maximum lengths are specified, the frame is not layout neutral, so chooses its size as the proposed size from its parent, not the size chosen by its child.

Finally it positions the child, using the alignment specified when creating the frame:

infinite frame with alignment

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