Flexible Frames

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

The layout process looks like:

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

I also showed that while you cannot change the size of a view, you can use this process to influence the result by using frames. A frame is added to a view using the .frame view modifier, creates a new view with the size given, and then places the view inside it.

It’s important to remember that the modifier does not change the frame 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:

image in too large frame

Which shows that while the frame proposes a larger size to its child, the child still decides on its size based on the source, and all that’s left to do for the frame is position it.

And likewise if we place the Image in a frame that’s too small, it does not respect the bounds:

image in too small frame

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

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 return 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, apparently using the height of 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. Conversely 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 example code above is the value of ContentView.body, but ContentView itself is also a view, and is the parent to its body. 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, the Image becomes as wide as is available:

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 decides on its size.
  5. Frame decides on its size based on the child, and its 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, 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 deciding it’s sized based on that of the child. If the child decides on 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 decides on a width of 101; the frame still applies its own minimum width, so decides on 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 decision as to its own size after the child has decided.

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 deciding it’s sized based on that of the child. If the child decides on a length that is larger than the frame’s maximum length, the frame’s length is still constrained to its maximum length, so 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 decides on a width of 101; the frame still applies its own maximum width, so decides on 50, so 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. It specifies the length to report to the parent, instead of using that reported by its child.

Image has ideal lengths of its source image, even when resizable, Text has ideal lengths based on the string. Setting your ideal lengths tends to be necessary when creating flexibly-sized shapes and other custom views.

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.

horizontal stack of three views

The stack receives a proposed size from its parent, and as we saw it first subtracts the necessary spacing between its children since it knows how many.

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

First the minimum size of each view is subtracted and allocated to each view, since the Image has that as a fixed value equal to its others, this allocates the full size the image needs and leaves the remainder to the text.

Next it takes into account the maximum size of each view, should the remainder divided equally be larger than the maximum size of any view, those views are then allocated their maximum size since it leaves more of the remainder for the rest.

Finally for any remaining views, the remainder is divided equally.

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 parent width 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 parent. Since the proposed width was 200, and the maximum width of the frame is 150, the frame’s width is 150, and the child is positioned inside it.

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 proposed size based on the parent. As that’s within its constraints, it chooses 200 and positions the child inside it.

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 takes a smaller size. Since the frame is not layout neutral it uses its minimum width as its closest to that of its parent, positions its child within that, 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 .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’s width becomes 200 and it positions the child inside it.

infinite frame

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

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

Both are required, because if we’d specified only the maximum width, the frame would have been layout neutral and chosen the size of its child rather 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 receives the parent size as a proposed size. Since both minimum and maximum lengths are specified, the frame is not layout neutral, so chooses its size as the parent size not the 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.