← All lessonsLesson 10 of 57 · Part 4: Views and layout
Lesson 10

How a view gets its size

How layout works: frames and sizing

You will buildA tool that prints the conversation between a parent and its child. Then, with what it shows you, three things for Trips: a strip of pictures that scrolls sideways, a row of three equal tiles, and a bar divided in proportion.

TimeAbout 100 minutes

WhyIn the last lesson you told stacks what to do. In this one you find out how they decide. In UIKit a view has a frame because somebody set it: you, or the constraint solver. In SwiftUI no view sets the size of another. A parent proposes a size, the child chooses its own, and the parent places it — three steps, the same at every level of every screen. Nearly everything that puzzles people about SwiftUI layout is one of those steps doing exactly what it should: a frame that does not resize its view, a button that will not grow, a text that is cut short when there is room. So in this lesson you watch the three steps happen.

Step 1

Open your project

  1. Open your Trips project as lesson 9 left it: double-click Trips.xcodeproj in Finder, or use File ▸ Open… (⌘O) in Xcode.

  2. If you did not finish lesson 9, or would rather start from a known state, download Trips-lesson-09.zip instead. It is the project exactly as it stood at the end of lesson 9. Unzip it and open Trips.xcodeproj.

  3. Run it (⌘R) to check that it builds. You should see where lesson 9 ended:

    Trips, as it stood at the end of lesson 9 — screenshot
    Trips, as it stood at the end of lesson 9Screenshot · iPhone 17, iOS 27.0
Step 2

A window on the conversation

Layout happens before anything is drawn, and it leaves nothing to look at. So first, a tool. Make a new Swift File named Watch, and replace everything in it:

New fileWatch.swift — the whole file
import SwiftUI

/// Prints what a view is offered, and what it takes. A tool for looking, not for shipping.
struct Watch: Layout {
    let name: String

    init(_ name: String) {
        self.name = name
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let size = subviews[0].sizeThatFits(proposal)
        print("\(name): offered \(text(proposal.width)) x \(text(proposal.height)), takes \(text(size.width)) x \(text(size.height))")
        return size
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        subviews[0].place(at: bounds.origin, proposal: proposal)
    }

    private func text(_ length: CGFloat?) -> String {
        guard let length else { return "nothing" }
        return length == .infinity ? "infinity" : "\(Int(length.rounded()))"
    }
}

You are not expected to follow every line: Layout is the next lesson's subject. Here only its shape matters. SwiftUI asks a container two things. How large are you? That is sizeThatFits, and it is handed the size that the container's own parent is offering. And where do your children go? That is placeSubviews. Watch is a container with one child and no opinions of its own. It passes the offer on to its child, prints the offer and the child's answer, and gives that answer as its own.

Now somewhere to use it. This lesson's experiments print to the console, so they have to run in the app and not in the canvas.

  1. Make a new view for them. Choose File ▸ New ▸ File from Template… (⌘N). Under User Interface, select SwiftUI View, and click Next. Name it Lab, and click Create.

New fileIn Lab.swift, replace the Lab struct with this
struct Lab: View {    var body: some View {        Text("Hello, World!")        Watch("text") {            Text("Seattle")        }        .border(.red)    }}
struct Lab: View {
    var body: some View {
        Watch("text") {
            Text("Seattle")
        }
        .border(.red)
    }
}

For the next four steps the app shows the lab instead of the list of trips. In TripsApp.swift, one line changes: ContentView(store: store) becomes Lab().

In TripsApp.swift, make these changes
import SwiftUI@mainstruct TripsApp: App {    @Environment(\.scenePhase) private var scenePhase    @State private var store = TripStore()    var body: some Scene {        WindowGroup {            ContentView(store: store)            Lab()        }        .onChange(of: scenePhase) { _, new in            if new == .background {                store.save()            }        }    }}
import SwiftUI

@main
struct TripsApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @State private var store = TripStore()

    var body: some Scene {
        WindowGroup {
            Lab()
        }
        .onChange(of: scenePhase) { _, new in
            if new == .background {
                store.save()
            }
        }
    }
}

Run the app (⌘R). Look at the screen, and then at the console:

One text, with a red border round it — screenshot
One text, with a red border round itScreenshot · iPhone 17, iOS 27.0
Console
text: offered 402 x 778, takes 53 x 20

One line, and it holds the whole of SwiftUI layout. Read it as a conversation.

StepWhoWhat happened
1. ProposeThe parent"You may have 402 by 778." That is the screen, less the status bar at the top and the home indicator at the bottom.
2. ChooseThe text"I will take 53 by 20." A text takes what its words need, and nothing more.
3. PlaceThe parentIt has 402 by 778 and a child of 53 by 20, so it puts the child in the middle.

A talk from WWDC 2019, Building Custom Views with SwiftUI, is still the clearest account of it: "the layout process has three steps. First, the root view offers the text a proposed size … Next, the text replies, well that's mighty generous of you but I'm really, I'm only this big. And in SwiftUI there's no way to force a size on your child, the parent has to respect that choice. And now the root view says alrighty, I need to put you somewhere so I'm putting you in the middle."

The second step is the one that differs from UIKit, where a parent set its child's frame and the child lived with it. Here the child decides. Not every child decides the same way. Try a color:

In Lab.swift, replace the Lab struct with this
struct Lab: View {    var body: some View {        Watch("text") {            Text("Seattle")        Watch("color") {            Color.yellow        }        .border(.red)        .padding(40)    }}
struct Lab: View {
    var body: some View {
        Watch("color") {
            Color.yellow
        }
        .border(.red)
        .padding(40)
    }
}

Run it (⌘R). The simulator shows

A color, with 40 points of padding round it — screenshot
A color, with 40 points of padding round itScreenshot · iPhone 17, iOS 27.0
Console
color: offered 322 x 698, takes 322 x 698

The color took everything it was offered. Apple says so of colors — "a color used as a view expands to fill all the space it's given" — and it is true of shapes and spacers too. Every view sits somewhere between these two: it takes what it needs, like the text, or what it is given, like the color.

Look at the offer as well. It is not 402 by 778 this time. .padding(40) is a parent too: it was offered the screen, kept 40 points on each side for itself, and passed on what was left. Every modifier that affects layout is a view between the one above it and the one below, taking part in the same three steps.

Step 3

A frame is a view

Now the modifier that everybody misreads. Put the text in a frame of 200 by 80, with a blue border to show where the frame is:

In Lab.swift, replace the Lab struct with this
struct Lab: View {    var body: some View {        Watch("color") {            Color.yellow        Watch("text") {            Text("Seattle")        }        .border(.red)        .padding(40)        .frame(width: 200, height: 80)        .border(.blue)    }}
struct Lab: View {
    var body: some View {
        Watch("text") {
            Text("Seattle")
        }
        .border(.red)
        .frame(width: 200, height: 80)
        .border(.blue)
    }
}

Run it (⌘R). The simulator shows

Blue: the frame. Red: the text — screenshot
Blue: the frame. Red: the textScreenshot · iPhone 17, iOS 27.0
Console
text: offered 200 x 80, takes 53 x 20

.frame(width: 200, height: 80) did not make the text 200 by 80. It could not: step 2 belongs to the child. What it made is a new view of 200 by 80 that has the text as its child. That view offered the text 200 by 80. The text took 53 by 20, as it always does. And the frame placed it — in the middle, since nobody said otherwise.

The same talk: "the frame is not a constraint in SwiftUI, it's just a view which you can think of like a picture frame. It proposes fixed dimensions for its child, but like every other view, the child ultimately chooses its own size." Apple's reference for the modifier begins with the same idea: it "positions this view within an invisible frame".

Step 3 is the frame's to decide, and that is what its alignment is for:

In Lab.swift, replace the Lab struct with this
struct Lab: View {    var body: some View {        Watch("text") {            Text("Seattle")        }        .border(.red)        .frame(width: 200, height: 80)        .frame(width: 200, height: 80, alignment: .bottomTrailing)        .border(.blue)    }}
struct Lab: View {
    var body: some View {
        Watch("text") {
            Text("Seattle")
        }
        .border(.red)
        .frame(width: 200, height: 80, alignment: .bottomTrailing)
        .border(.blue)
    }
}

Run it (⌘R). The simulator shows

alignment: .bottomTrailing: the same frame, the same text, another place — screenshot
alignment: .bottomTrailing: the same frame, the same text, another placeScreenshot · iPhone 17, iOS 27.0

A frame need not have a fixed size. Give it a maximum instead of a width:

In Lab.swift, replace the Lab struct with this
struct Lab: View {    var body: some View {        Watch("text") {            Text("Seattle")        }        .border(.red)        .frame(width: 200, height: 80, alignment: .bottomTrailing)        .frame(maxWidth: .infinity, alignment: .leading)        .border(.blue)        .padding()    }}
struct Lab: View {
    var body: some View {
        Watch("text") {
            Text("Seattle")
        }
        .border(.red)
        .frame(maxWidth: .infinity, alignment: .leading)
        .border(.blue)
        .padding()
    }
}

Run it (⌘R). The simulator shows

.frame(maxWidth: .infinity, alignment: .leading) — screenshot
.frame(maxWidth: .infinity, alignment: .leading)Screenshot · iPhone 17, iOS 27.0
Console
text: offered 370 x 746, takes 53 x 20
text: offered 370 x 20, takes 53 x 20

The blue frame is as wide as it is allowed to be — the screen, less the padding. The text inside it is still 53 points wide, and sits at the frame's leading edge because the alignment says so. This is the answer to "how do I make it full width?", and to the last lesson's edge case about a column that stayed in the middle. You do not stretch the view. You put it in a frame that stretches, and say where in that frame it belongs.

There are two lines in the console this time. A parent may ask its child more than once. Here the frame asked once to find out how tall it should be, and once again when it placed the text, with the size it had settled on.

Step 4

A text under pressure

So what does a child do when it is offered less than it would like? A text has three answers. Here is one sentence three times, each in a frame 150 points wide:

In Lab.swift, replace the Lab struct with this
struct Lab: View {    let note = "Pike Place Market first thing on Saturday"    var body: some View {        Watch("text") {            Text("Seattle")        VStack(spacing: 30) {            Text(note)                .border(.red)                .frame(width: 150)                .border(.blue)            Text(note)                .lineLimit(1)                .border(.red)                .frame(width: 150)                .border(.blue)            Text(note)                .fixedSize()                .border(.red)                .frame(width: 150)                .border(.blue)        }        .border(.red)        .frame(maxWidth: .infinity, alignment: .leading)        .border(.blue)        .padding()    }}
struct Lab: View {
    let note = "Pike Place Market first thing on Saturday"

    var body: some View {
        VStack(spacing: 30) {
            Text(note)
                .border(.red)
                .frame(width: 150)
                .border(.blue)
            Text(note)
                .lineLimit(1)
                .border(.red)
                .frame(width: 150)
                .border(.blue)
            Text(note)
                .fixedSize()
                .border(.red)
                .frame(width: 150)
                .border(.blue)
        }
    }
}

Run it (⌘R). The simulator shows

The same sentence, offered 150 points, three ways — screenshot
The same sentence, offered 150 points, three waysScreenshot · iPhone 17, iOS 27.0
The textWhat it does with an offer of 150 points
As it comesWraps. It stays inside the width and takes as many lines as it needs — the frame gave no height, so it can.
.lineLimit(1)Keeps to one line, and shows "…" where the rest would have been.
.fixedSize()Ignores the offer. It takes its ideal size, the whole sentence on one line, and sticks out of its frame on both sides.

Look at the blue borders: the frame is 150 points wide all three times. Only the child's choice differs. Apple describes fixedSize() as "the creation of a counter proposal to the view size proposed to a view by its parent" — the child answering "no: this much." It is the right tool when a text must never be cut short, and you have made sure that there is room.

Step 5

Two views, one space

A frame has one child. A stack has several, and has to share its space among them. Watch it do that. Two texts that are too long to sit side by side, in a row 300 points wide:

In Lab.swift, replace the Lab struct with this
struct Lab: View {    let note = "Pike Place Market first thing on Saturday"    var body: some View {        VStack(spacing: 30) {            Text(note)                .border(.red)                .frame(width: 150)                .border(.blue)            Text(note)                .lineLimit(1)                .border(.red)                .frame(width: 150)                .border(.blue)            Text(note)                .fixedSize()                .border(.red)                .frame(width: 150)                .border(.blue)        HStack {            Watch("name") {                Text("San Pedro de Atacama, Chile")                    .lineLimit(1)            }            .border(.red)            Watch("nights") {                Text("12 nights in November")                    .lineLimit(1)            }            .border(.red)        }        .frame(width: 300)        .border(.blue)    }}
struct Lab: View {
    var body: some View {
        HStack {
            Watch("name") {
                Text("San Pedro de Atacama, Chile")
                    .lineLimit(1)
            }
            .border(.red)
            Watch("nights") {
                Text("12 nights in November")
                    .lineLimit(1)
            }
            .border(.red)
        }
        .frame(width: 300)
        .border(.blue)
    }
}

Run it (⌘R). The simulator shows

Both cut short — screenshot
Both cut shortScreenshot · iPhone 17, iOS 27.0
Console
name: offered infinity x 778, takes 220 x 20
name: offered 0 x 778, takes 0 x 20
nights: offered infinity x 778, takes 169 x 20
nights: offered 0 x 778, takes 0 x 20
nights: offered 146 x 778, takes 143 x 20
name: offered 149 x 778, takes 145 x 20

Six lines, and they are the whole of how a stack works. Take them in order.

LinesWhat the stack is doing
1 to 4Finding out how flexible each child is, by making offers it does not mean: everything (infinity), then nothing (0). The name would take 220 points if it could, the nights 169, and each can shrink to nothing.
5The real offer. The row is 300 points wide and keeps 8 for the gap, which leaves 292: half each, 146. The less flexible child is asked first. That is the nights. It takes 143, cutting itself short.
6What is left goes to the other: 292 less 143 is 149. The name takes 145 of them.

Apple's documentation says of every container that it measures its children "by proposing several sizes and looking at the responses", and the 2019 talk gives the rule you have just watched: "we divide the remaining space in … equal parts and then we propose one of those as the size for the least flexible child", deduct what it took, "and repeat."

The result is fair, and useless: two halves of two sentences. When one child matters more than the other, say so. Add .layoutPriority(1) to the nights:

In Lab.swift, replace the Lab struct with this
struct Lab: View {    var body: some View {        HStack {            Watch("name") {                Text("San Pedro de Atacama, Chile")                    .lineLimit(1)            }            .border(.red)            Watch("nights") {                Text("12 nights in November")                    .lineLimit(1)            }            .border(.red)            .layoutPriority(1)        }        .frame(width: 300)        .border(.blue)    }}
struct Lab: View {
    var body: some View {
        HStack {
            Watch("name") {
                Text("San Pedro de Atacama, Chile")
                    .lineLimit(1)
            }
            .border(.red)
            Watch("nights") {
                Text("12 nights in November")
                    .lineLimit(1)
            }
            .border(.red)
            .layoutPriority(1)
        }
        .frame(width: 300)
        .border(.blue)
    }
}

Run it (⌘R). The simulator shows

.layoutPriority(1) on the nights: whole, at the name's expense — screenshot
.layoutPriority(1) on the nights: whole, at the name's expenseScreenshot · iPhone 17, iOS 27.0
Console
name: offered 0 x 778, takes 0 x 20
nights: offered 292 x 778, takes 169 x 20
name: offered 123 x 778, takes 115 x 20

This time the stack asked the name only for its minimum. Then it offered the nights everything else — all 292 points — and the nights took the 169 they need. The name was left with 123. Apple: "A parent layout offers the child views with the highest layout priority all the space offered to the parent minus the minimum space required for all its lower-priority children."

Step 6

A fraction of the container

The lab has done its work. Put the app back as it was: in TripsApp.swift, Lab() becomes ContentView(store: store) again:

In TripsApp.swift, make these changes
import SwiftUI@mainstruct TripsApp: App {    @Environment(\.scenePhase) private var scenePhase    @State private var store = TripStore()    var body: some Scene {        WindowGroup {            Lab()            ContentView(store: store)        }        .onChange(of: scenePhase) { _, new in            if new == .background {                store.save()            }        }    }}
import SwiftUI

@main
struct TripsApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @State private var store = TripStore()

    var body: some Scene {
        WindowGroup {
            ContentView(store: store)
        }
        .onChange(of: scenePhase) { _, new in
            if new == .background {
                store.save()
            }
        }
    }
}

And delete Lab.swift: select it in the Project navigator, press the Delete key, and choose Move to Trash. Keep Watch.swift: you will open it up in the next lesson.

Now use what you saw. First, a strip of the trips' pictures across the top of the screen, to flick through sideways.

  1. Choose File ▸ New ▸ File from Template… (⌘N), select SwiftUI View, and name it PhotoStrip.

New fileIn PhotoStrip.swift, replace the PhotoStrip struct with this
struct PhotoStrip: View {    let trips: [Trip]    var body: some View {        Text("Hello, World!")        ScrollView(.horizontal) {            HStack(spacing: 12) {                ForEach(trips) { trip in                    Image(trip.imageName)                        .resizable()                        .scaledToFill()                        .frame(width: 300, height: 180)                        .clipShape(RoundedRectangle(cornerRadius: 12))                }            }        }        .safeAreaPadding(.horizontal)        .scrollIndicators(.hidden)    }}
struct PhotoStrip: View {
    let trips: [Trip]

    var body: some View {
        ScrollView(.horizontal) {
            HStack(spacing: 12) {
                ForEach(trips) { trip in
                    Image(trip.imageName)
                        .resizable()
                        .scaledToFill()
                        .frame(width: 300, height: 180)
                        .clipShape(RoundedRectangle(cornerRadius: 12))
                }
            }
        }
        .safeAreaPadding(.horizontal)
        .scrollIndicators(.hidden)
    }
}
In PhotoStrip.swift, replace the preview with this
#Preview {    PhotoStrip()    PhotoStrip(trips: Trip.samples)}
#Preview {
    PhotoStrip(trips: Trip.samples)
}

The picture is lesson 8's recipe for a photograph cropped to a box: .scaledToFill(), a frame, and a clip shape.

Open the canvas (⌥⌘↩):

Each picture in a frame 300 points wide and 180 tall — screenshot
Each picture in a frame 300 points wide and 180 tallScreenshot · iPhone 17, iOS 27.0

It looks right, and one number in it is a guess: 300. It was found by trying, on this phone, until the next picture just showed at the edge. The width you mean is not "300 points". It is "most of the width, whatever the width turns out to be" — on another phone, on an iPad, in half of a split screen.

The picture cannot learn that width by being offered it. Inside a scroll view that scrolls sideways, nothing is offered a width at all: the content may be as wide as it likes. So there is a frame that asks the container instead. Replace the fixed frame by two: one for the width, which asks the container, and one for the height, which stays at 180 points:

In PhotoStrip.swift, replace the PhotoStrip struct with this
struct PhotoStrip: View {    let trips: [Trip]    var body: some View {        ScrollView(.horizontal) {            HStack(spacing: 12) {                ForEach(trips) { trip in                    Image(trip.imageName)                        .resizable()                        .scaledToFill()                        .frame(width: 300, height: 180)                        .containerRelativeFrame(.horizontal, count: 5, span: 4, spacing: 12)                        .frame(height: 180)                        .clipShape(RoundedRectangle(cornerRadius: 12))                }            }        }        .safeAreaPadding(.horizontal)        .scrollIndicators(.hidden)    }}
struct PhotoStrip: View {
    let trips: [Trip]

    var body: some View {
        ScrollView(.horizontal) {
            HStack(spacing: 12) {
                ForEach(trips) { trip in
                    Image(trip.imageName)
                        .resizable()
                        .scaledToFill()
                        .containerRelativeFrame(.horizontal, count: 5, span: 4, spacing: 12)
                        .frame(height: 180)
                        .clipShape(RoundedRectangle(cornerRadius: 12))
                }
            }
        }
        .safeAreaPadding(.horizontal)
        .scrollIndicators(.hidden)
    }
}

The canvas now:

Each picture four fifths of the scroll view's width — screenshot
Each picture four fifths of the scroll view's widthScreenshot · iPhone 17, iOS 27.0

.containerRelativeFrame(.horizontal, count: 5, span: 4, spacing: 12) reads: divide the width of the nearest container into 5 columns with 12 points between them, and make this view 4 columns wide. Apple calls it a frame "with a size relative to the nearest container", and the nearest container here is the scroll view. .safeAreaPadding(.horizontal) on the scroll view is what keeps the first picture off the edge of the screen, and the container's width is counted inside that padding.

Two more lines make the strip stop on a picture and not between two:

In PhotoStrip.swift, replace the PhotoStrip struct with this
struct PhotoStrip: View {    let trips: [Trip]    var body: some View {        ScrollView(.horizontal) {            HStack(spacing: 12) {                ForEach(trips) { trip in                    Image(trip.imageName)                        .resizable()                        .scaledToFill()                        .containerRelativeFrame(.horizontal, count: 5, span: 4, spacing: 12)                        .frame(height: 180)                        .clipShape(RoundedRectangle(cornerRadius: 12))                }            }            .scrollTargetLayout()        }        .scrollTargetBehavior(.viewAligned)        .safeAreaPadding(.horizontal)        .scrollIndicators(.hidden)    }}
struct PhotoStrip: View {
    let trips: [Trip]

    var body: some View {
        ScrollView(.horizontal) {
            HStack(spacing: 12) {
                ForEach(trips) { trip in
                    Image(trip.imageName)
                        .resizable()
                        .scaledToFill()
                        .containerRelativeFrame(.horizontal, count: 5, span: 4, spacing: 12)
                        .frame(height: 180)
                        .clipShape(RoundedRectangle(cornerRadius: 12))
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
        .safeAreaPadding(.horizontal)
        .scrollIndicators(.hidden)
    }
}

.scrollTargetLayout() marks the row as the thing whose children are places to stop, and .scrollTargetBehavior(.viewAligned) tells the scroll view to stop on one of them. Nothing in a still picture shows it, so you will try it in step 9, in the running app. You will meet both modifiers again in the interview problem at the end of this part.

Step 7

Three equal tiles

Under the strip, a summary: how many trips, how many nights, and what they cost. Make another SwiftUI View, named TripSummary.

New fileIn TripSummary.swift, replace the TripSummary struct with this
struct TripSummary: View {    let trips: [Trip]    var body: some View {        Text("Hello, World!")        HStack {            Text("^[\(trips.count) trip](inflect: true)")                .padding(10)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))            Text("^[\(nights) night](inflect: true)")                .padding(10)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))            Text(total, format: .currency(code: "USD"))                .padding(10)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))        }        .font(.subheadline.weight(.semibold))        .padding(.horizontal)    }    private var nights: Int {        trips.reduce(0) { $0 + $1.nights }    }    private var total: Decimal {        trips.reduce(0) { $0 + $1.price }    }}
struct TripSummary: View {
    let trips: [Trip]

    var body: some View {
        HStack {
            Text("^[\(trips.count) trip](inflect: true)")
                .padding(10)
                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
            Text("^[\(nights) night](inflect: true)")
                .padding(10)
                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
            Text(total, format: .currency(code: "USD"))
                .padding(10)
                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
        }
        .font(.subheadline.weight(.semibold))
        .padding(.horizontal)
    }

    private var nights: Int {
        trips.reduce(0) { $0 + $1.nights }
    }

    private var total: Decimal {
        trips.reduce(0) { $0 + $1.price }
    }
}
In TripSummary.swift, replace the preview with this
#Preview {    TripSummary()    TripSummary(trips: Trip.samples)}
#Preview {
    TripSummary(trips: Trip.samples)
}

The canvas:

Three tiles, each as wide as its words — screenshot
Three tiles, each as wide as its wordsScreenshot · iPhone 17, iOS 27.0

Each text has padding and a gray background, and — as you now know — each is as wide as its words and no wider. The row hugs the three of them and sits in the middle. The design wants three equal tiles, filling the width.

You know the tool: .frame(maxWidth: .infinity). Add it to each of the three, as the last line:

In TripSummary.swift, replace the TripSummary struct with this
struct TripSummary: View {    let trips: [Trip]    var body: some View {        HStack {            Text("^[\(trips.count) trip](inflect: true)")                .padding(10)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))                .frame(maxWidth: .infinity)            Text("^[\(nights) night](inflect: true)")                .padding(10)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))                .frame(maxWidth: .infinity)            Text(total, format: .currency(code: "USD"))                .padding(10)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))                .frame(maxWidth: .infinity)        }        .font(.subheadline.weight(.semibold))        .padding(.horizontal)    }    private var nights: Int {        trips.reduce(0) { $0 + $1.nights }    }    private var total: Decimal {        trips.reduce(0) { $0 + $1.price }    }}
struct TripSummary: View {
    let trips: [Trip]

    var body: some View {
        HStack {
            Text("^[\(trips.count) trip](inflect: true)")
                .padding(10)
                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
                .frame(maxWidth: .infinity)
            Text("^[\(nights) night](inflect: true)")
                .padding(10)
                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
                .frame(maxWidth: .infinity)
            Text(total, format: .currency(code: "USD"))
                .padding(10)
                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
                .frame(maxWidth: .infinity)
        }
        .font(.subheadline.weight(.semibold))
        .padding(.horizontal)
    }

    private var nights: Int {
        trips.reduce(0) { $0 + $1.nights }
    }

    private var total: Decimal {
        trips.reduce(0) { $0 + $1.price }
    }
}

The canvas:

The frames are equal. The gray is not — screenshot
The frames are equal. The gray is notScreenshot · iPhone 17, iOS 27.0

Something happened: the tiles have moved apart. But the gray boxes are the size they were. Think of the frame as a view. The gray background was put on the padded text, which is small. Then that small gray box was put in a wide frame, which centered it. The three frames really are equal thirds of the row — a stack shares its space equally among children that are equally flexible, as you watched in step 5 — and each has a small gray box in its middle.

For the gray to be wide, the background has to be put on the wide thing. Move the frame up one line, above the background:

In TripSummary.swift, replace the TripSummary struct with this
struct TripSummary: View {    let trips: [Trip]    var body: some View {        HStack {            Text("^[\(trips.count) trip](inflect: true)")                .padding(10)                .frame(maxWidth: .infinity)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))                .frame(maxWidth: .infinity)            Text("^[\(nights) night](inflect: true)")                .padding(10)                .frame(maxWidth: .infinity)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))                .frame(maxWidth: .infinity)            Text(total, format: .currency(code: "USD"))                .padding(10)                .frame(maxWidth: .infinity)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))                .frame(maxWidth: .infinity)        }        .font(.subheadline.weight(.semibold))        .padding(.horizontal)    }    private var nights: Int {        trips.reduce(0) { $0 + $1.nights }    }    private var total: Decimal {        trips.reduce(0) { $0 + $1.price }    }}
struct TripSummary: View {
    let trips: [Trip]

    var body: some View {
        HStack {
            Text("^[\(trips.count) trip](inflect: true)")
                .padding(10)
                .frame(maxWidth: .infinity)
                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
            Text("^[\(nights) night](inflect: true)")
                .padding(10)
                .frame(maxWidth: .infinity)
                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
            Text(total, format: .currency(code: "USD"))
                .padding(10)
                .frame(maxWidth: .infinity)
                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
        }
        .font(.subheadline.weight(.semibold))
        .padding(.horizontal)
    }

    private var nights: Int {
        trips.reduce(0) { $0 + $1.nights }
    }

    private var total: Decimal {
        trips.reduce(0) { $0 + $1.price }
    }
}

The canvas:

Padding, then the frame, then the background — screenshot
Padding, then the frame, then the backgroundScreenshot · iPhone 17, iOS 27.0

The same three modifiers in another order, and another result. Each modifier wraps everything above it, so "which view is this background on?" has a definite answer, and you can always work it out by reading upward. It is also exactly why a button does not grow when you put .frame(maxWidth: .infinity) on it — one of this lesson's edge cases. The order of modifiers is the next lesson's subject. So is the fact that you have just typed the same three lines three times.

Step 8

When you need the number

Last, a bar under the tiles that shows how the nights are shared among the trips: a segment for each trip, as wide as its share of the nights, yellow if the trip is a favorite.

Stacks cannot do this. A stack shares space equally, or by need. It does not share by a ratio that you have computed. For that you need a number: how wide am I? The view that answers is GeometryReader. It hands its content a proxy that knows the size the reader was given:

In TripSummary.swift, replace the TripSummary struct with this
struct TripSummary: View {    let trips: [Trip]    var body: some View {        HStack {            Text("^[\(trips.count) trip](inflect: true)")                .padding(10)                .frame(maxWidth: .infinity)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))            Text("^[\(nights) night](inflect: true)")                .padding(10)                .frame(maxWidth: .infinity)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))            Text(total, format: .currency(code: "USD"))                .padding(10)                .frame(maxWidth: .infinity)                .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))        VStack(spacing: 10) {            HStack {                Text("^[\(trips.count) trip](inflect: true)")                    .padding(10)                    .frame(maxWidth: .infinity)                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))                Text("^[\(nights) night](inflect: true)")                    .padding(10)                    .frame(maxWidth: .infinity)                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))                Text(total, format: .currency(code: "USD"))                    .padding(10)                    .frame(maxWidth: .infinity)                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))            }            GeometryReader { proxy in                HStack(spacing: 0) {                    ForEach(trips) { trip in                        Rectangle()                            .fill(trip.isFavorite ? Color.yellow : Color.gray)                            .padding(.horizontal, 1)                            .frame(width: proxy.size.width * Double(trip.nights) / Double(nights))                    }                }            }        }        .font(.subheadline.weight(.semibold))        .padding(.horizontal)    }    private var nights: Int {        trips.reduce(0) { $0 + $1.nights }    }    private var total: Decimal {        trips.reduce(0) { $0 + $1.price }    }}
struct TripSummary: View {
    let trips: [Trip]

    var body: some View {
        VStack(spacing: 10) {
            HStack {
                Text("^[\(trips.count) trip](inflect: true)")
                    .padding(10)
                    .frame(maxWidth: .infinity)
                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
                Text("^[\(nights) night](inflect: true)")
                    .padding(10)
                    .frame(maxWidth: .infinity)
                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
                Text(total, format: .currency(code: "USD"))
                    .padding(10)
                    .frame(maxWidth: .infinity)
                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
            }
            GeometryReader { proxy in
                HStack(spacing: 0) {
                    ForEach(trips) { trip in
                        Rectangle()
                            .fill(trip.isFavorite ? Color.yellow : Color.gray)
                            .padding(.horizontal, 1)
                            .frame(width: proxy.size.width * Double(trip.nights) / Double(nights))
                    }
                }
            }
        }
        .font(.subheadline.weight(.semibold))
        .padding(.horizontal)
    }

    private var nights: Int {
        trips.reduce(0) { $0 + $1.nights }
    }

    private var total: Decimal {
        trips.reduce(0) { $0 + $1.price }
    }
}

The canvas:

The bar is right. It is also the height of the screen — screenshot
The bar is right. It is also the height of the screenScreenshot · iPhone 17, iOS 27.0

The widths are right: five eighteenths, one eighteenth, twelve eighteenths. And the bar has swallowed the screen. A GeometryReader is one of the views that takes everything it is offered. Apple's reference says it in one line: "This view returns a flexible preferred size to its parent layout." The rectangles inside it are shapes, which do the same. Nothing in that chain has a height of its own, so they all took the height on offer.

So give the reader a height — with a frame, which you now understand — and round off the ends:

In TripSummary.swift, replace the TripSummary struct with this
struct TripSummary: View {    let trips: [Trip]    var body: some View {        VStack(spacing: 10) {            HStack {                Text("^[\(trips.count) trip](inflect: true)")                    .padding(10)                    .frame(maxWidth: .infinity)                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))                Text("^[\(nights) night](inflect: true)")                    .padding(10)                    .frame(maxWidth: .infinity)                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))                Text(total, format: .currency(code: "USD"))                    .padding(10)                    .frame(maxWidth: .infinity)                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))            }            GeometryReader { proxy in                HStack(spacing: 0) {                    ForEach(trips) { trip in                        Rectangle()                            .fill(trip.isFavorite ? Color.yellow : Color.gray)                            .padding(.horizontal, 1)                            .frame(width: proxy.size.width * Double(trip.nights) / Double(nights))                    }                }            }            .frame(height: 8)            .clipShape(Capsule())        }        .font(.subheadline.weight(.semibold))        .padding(.horizontal)    }    private var nights: Int {        trips.reduce(0) { $0 + $1.nights }    }    private var total: Decimal {        trips.reduce(0) { $0 + $1.price }    }}
struct TripSummary: View {
    let trips: [Trip]

    var body: some View {
        VStack(spacing: 10) {
            HStack {
                Text("^[\(trips.count) trip](inflect: true)")
                    .padding(10)
                    .frame(maxWidth: .infinity)
                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
                Text("^[\(nights) night](inflect: true)")
                    .padding(10)
                    .frame(maxWidth: .infinity)
                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
                Text(total, format: .currency(code: "USD"))
                    .padding(10)
                    .frame(maxWidth: .infinity)
                    .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
            }
            GeometryReader { proxy in
                HStack(spacing: 0) {
                    ForEach(trips) { trip in
                        Rectangle()
                            .fill(trip.isFavorite ? Color.yellow : Color.gray)
                            .padding(.horizontal, 1)
                            .frame(width: proxy.size.width * Double(trip.nights) / Double(nights))
                    }
                }
            }
            .frame(height: 8)
            .clipShape(Capsule())
        }
        .font(.subheadline.weight(.semibold))
        .padding(.horizontal)
    }

    private var nights: Int {
        trips.reduce(0) { $0 + $1.nights }
    }

    private var total: Decimal {
        trips.reduce(0) { $0 + $1.price }
    }
}

The canvas:

A frame 8 points tall round the reader, clipped to a capsule — screenshot
A frame 8 points tall round the reader, clipped to a capsuleScreenshot · iPhone 17, iOS 27.0
In the codeWhat it does
GeometryReader { proxy in … }Takes all the space it is offered, and tells its content how much that was: proxy.size.
.frame(width: proxy.size.width * Double(trip.nights) / Double(nights))A segment's share of the width. This is the line that needs the number.
.padding(.horizontal, 1), above the frameThe padding is inside the frame, so the segment is exactly its share and the color stops 1 point short at each side. That is the gap between segments.
.frame(height: 8) on the readerThe reader takes what it is offered, so offer it 8 points.
Reach for it last

A GeometryReader changes the layout round it, as you have just seen, and it is the tool that people reach for too soon. Hacking with Swift's advice for the interview question "When would you use GeometryReader?" ends: "For bonus points you might want to add that GeometryReader is frequently over-used." A fraction of the screen is containerRelativeFrame. Equal shares are .frame(maxWidth: .infinity). Use the reader when a size has to be computed, as here.

Where this comes from: Hacking with Swift interview question: When would you use GeometryReader? · GeometryReader

Step 9

On the screen

Put the two new views at the top of the list. In ContentView.swift:

In ContentView.swift, replace the ContentView struct with this
struct ContentView: View {    let store: TripStore    @State private var favoritesOnly = false    var body: some View {        ScrollView {            LazyVStack {            LazyVStack(spacing: 12) {                PhotoStrip(trips: store.trips)                TripSummary(trips: store.trips)                Toggle("Favorites only", isOn: $favoritesOnly)                    .padding(.horizontal)                ForEach(store.trips.filter { !favoritesOnly || $0.isFavorite }) { trip in                    TripCard(trip: trip)                }            }        }    }}
struct ContentView: View {
    let store: TripStore
    @State private var favoritesOnly = false

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 12) {
                PhotoStrip(trips: store.trips)
                TripSummary(trips: store.trips)
                Toggle("Favorites only", isOn: $favoritesOnly)
                    .padding(.horizontal)
                ForEach(store.trips.filter { !favoritesOnly || $0.isFavorite }) { trip in
                    TripCard(trip: trip)
                }
            }
        }
    }
}

Run it (⌘R). The simulator shows

Trips at the end of lesson 10 — screenshot
Trips at the end of lesson 10Screenshot · iPhone 17, iOS 27.0

The stack has a spacing now, LazyVStack(spacing: 12), so that the strip, the summary, the switch and the cards are evenly apart. Now put a finger on the strip, drag it a little way to the left, and let go:

After a short drag: the strip has moved on to the second picture, and stopped on it — screenshot
After a short drag: the strip has moved on to the second picture, and stopped on itScreenshot · iPhone 17, iOS 27.0

A short drag was enough, and the strip did not stop half-way: that is .viewAligned. Scroll the list up and down as well. The strip that scrolls sideways lives happily inside the list that scrolls up and down, and each picture is still four fifths of the width, because its nearest container is the strip and not the list.

What you built

You moved one line in this lesson and the tiles changed. The next lesson is about why: what a modifier is, why their order matters everywhere, and how to give the three lines you typed three times a name of their own. Then grids — and the inside of Watch.

The finished project

Stuck, or want to compare with your own? This is the project as it stands at the end of this lesson, built and run before it was put here.

Flash cards

20 cards for this lesson, each labelled with its topic. 11 ask how you would write something; the other 9 are trick questions, written to catch a misunderstanding. Answer in your head first, then tap the card.

Syntax · FramesMake a view as wide as its container, with its content at the leading edge.
Text("Seattle")
    .frame(maxWidth: .infinity, alignment: .leading)
    .border(.blue)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.frame(maxWidth: .infinity, alignment: .leading). The frame takes all the width it is offered, and the alignment says where in the frame the view sits. The view itself is no wider than before.

Syntax · FramesPut a view in a box 200 points wide and 80 tall, in the box's bottom trailing corner.
Text("Seattle")
    .frame(width: 200, height: 80, alignment: .bottomTrailing)
    .border(.blue)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.frame(width:height:alignment:). A frame is a view with one child. It offers the child its own size and places it by the alignment — in the center, unless you say otherwise.

Syntax · TextA text that keeps its whole sentence on one line, whatever width it is offered.
Text("Pike Place Market first thing on Saturday")
    .fixedSize()
    .frame(width: 150)
    .border(.blue)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.fixedSize(). The text takes its ideal size and ignores the offer. Here it is offered 150 points and sticks out of the blue frame on both sides, so use it only where you know there is room.

Syntax · TextA long text in a place that offers too little height. Let it wrap at the width it is given, and take every line it needs.
Text(note)
    .fixedSize(horizontal: false, vertical: true)
    .frame(width: 240, height: 50)
    .border(.blue)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.fixedSize(horizontal: false, vertical: true): fixed at its ideal size up and down only. The width is still the one it is offered, so it wraps; the height is whatever its lines need, so nothing is cut off (the lower text; the upper is the same text without the modifier). It is the usual cure for a text that ends in "…" when it had room to grow.

Syntax · StacksTwo texts in a row that is too narrow for both. Make the second the one that stays whole.
HStack {
    Text("San Pedro de Atacama, Chile")
    Text("12 nights in November")
        .layoutPriority(1)
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.layoutPriority(1). Every view starts at priority 0. A stack offers its space to the children with the highest priority first, and the others share what is left.

Syntax · FramesThree views in a row, each exactly a third of the width.
HStack {
    Text("3 trips")
        .frame(maxWidth: .infinity)
        .border(.blue)
    Text("18 nights")
        .frame(maxWidth: .infinity)
        .border(.blue)
    Text("$5,749.50")
        .frame(maxWidth: .infinity)
        .border(.blue)
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.frame(maxWidth: .infinity) on each. Three children that are equally willing to stretch are given equal shares. Without the frames, each would be as wide as its own words.

Syntax · ContainersIn a scroll view that scrolls sideways, make each card four fifths of the scroll view's width.
Text(name)
    .font(.title)
    .frame(height: 160)
    .containerRelativeFrame(.horizontal, count: 5, span: 4, spacing: 12)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.containerRelativeFrame(.horizontal, count: 5, span: 4, spacing: 12): divide the container's width into 5 columns with 12 points between them, and take 4. The container is the nearest scroll view — not the stack the view happens to be in.

Syntax · GeometryReaderDraw a bar that is 40% as wide as the space it is in, whatever that turns out to be.
GeometryReader { proxy in
    Capsule()
        .fill(.orange)
        .frame(width: proxy.size.width * 0.4)
}
.frame(height: 8)
.background(.quaternary, in: Capsule())
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

A GeometryReader: proxy.size is the size the reader was given. The reader takes everything it is offered, which is why it needs .frame(height: 8). Below it in the picture is the same value in the system's own ProgressView(value: 0.4) — for a plain progress bar, use that.

Syntax · ButtonsA button whose colored background is as wide as the screen.
Button {
    print("booked")
} label: {
    Text("Book this trip")
        .frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The frame goes on the label, inside the button. The style draws its background round the label, so a wide label makes a wide button.

Syntax · ShapesA rectangle as wide as it is allowed to be, and always 16 to 9.
Rectangle()
    .fill(.teal)
    .aspectRatio(16 / 9, contentMode: .fit)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.aspectRatio(16 / 9, contentMode: .fit). A shape takes all the space it is offered. This modifier makes it take the largest piece of that space that has the right proportions.

Syntax · FramesA view that is never smaller than 44 by 44 points, but may be larger.
Image(systemName: "star")
    .frame(minWidth: 44, minHeight: 44)
    .border(.blue)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

.frame(minWidth: 44, minHeight: 44). A minimum, not a size: content that needs more still gets it. On the left, the same star without the frame. Apple's guidelines: "a button needs a hit region of at least 44x44 pt".

Trick question · FramesText("Seattle").frame(width: 200, height: 80). How large is the text now?
Text("Seattle")
    .border(.red)
    .frame(width: 200, height: 80)
    .border(.blue)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The size it always was (the red border). The frame is a separate view of 200 by 80 (the blue border), with the text placed in its middle. No parent can make a child a size that the child does not choose.

Trick question · FramesThe same three modifiers in two orders: padding, a yellow background, a frame 200 points wide — or padding, the frame, the background. Which yellow box is 200 points wide?
Text("Seattle")
    .padding()
    .background(.yellow)
    .frame(width: 200)
    .border(.blue)
Text("Seattle")
    .padding()
    .frame(width: 200)
    .background(.yellow)
    .border(.blue)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The second. A background is as large as the view it is put on. In the first, that view is the padded text; the frame comes afterward and only surrounds the yellow box. In the second, the frame is already there when the background arrives. (The blue border is the frame, both times.)

Trick question · Safe areaColor.red is the whole of body. How much of the screen is red?
Color.red
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

All of it except a band at the top and one at the bottom. A color takes everything it is offered, and a view is offered the safe area: the screen less the status bar and the home indicator. Color.red.ignoresSafeArea() reaches the edges.

Trick question · ButtonsA .borderedProminent button, and after the style, .frame(maxWidth: .infinity). Is the blue part as wide as the screen?
Button("Book this trip") { }
    .buttonStyle(.borderedProminent)
    .frame(maxWidth: .infinity)
    .border(.red)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

No (the upper one). The frame is as wide as the screen — the red border — and the finished button sits in the middle of it, as small as it was. To widen the button, put the frame on its label (the lower one).

Trick question · GeometryReaderA GeometryReader with one small text in it, between two other texts in a VStack. How tall is the reader, and where in it is the small text?
GeometryReader { proxy in
    Text("\(Int(proxy.size.width)) x \(Int(proxy.size.height))")
        .border(.red)
}
.background(.yellow.opacity(0.4))
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

As tall as everything that was left — the text in it reports the size the reader was given, 402 by 720 — and the text is in the top leading corner. A reader takes all the space it is offered, and it does not center its content as other containers do.

Trick question · Position.offset(y: 30) on the second of five texts in a column. Does the third text move down to make room?
Text("one").border(.red)
Text("offset").offset(y: 30).border(.blue)
Text("three").border(.red)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

No. offset moves where a view is drawn, after layout is over: the word "offset" lands on top of "three". Its blue border was added after the offset, and shows the place that layout kept for it. padding(.top, 30) on the fourth is layout: everything below it moved.

Trick question · FramesA text in a frame of 300 by 60 (red), and round that a frame of 100 by 30 (blue). Which size do the neighbors see?
Text("Seattle")
    .frame(width: 300, height: 60)
    .border(.red)
    .frame(width: 100, height: 30)
    .border(.blue)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

100 by 30: the outer frame. The inner frame is still 300 by 60 and sticks out of the outer one on every side. The texts above and below are placed against the blue box, 30 points from it, and never hear about the red one.

Trick question · Scroll viewsA text with .frame(maxWidth: .infinity) and a yellow background, inside a ScrollView(.horizontal). How wide is the yellow?
ScrollView(.horizontal) {
    Text("Seattle")
        .frame(maxWidth: .infinity)
        .background(.yellow)
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

As wide as the word. maxWidth: .infinity means "all the width I am offered", and sideways a horizontal scroll view offers none: its content may be as wide as it likes. With no offer to take, the frame is the size of its child. Here the tool is containerRelativeFrame(.horizontal).

Trick question · FramesA short text with .frame(maxWidth: 100). Is the frame as wide as the text, or 100 points?
Text("Seattle")
    .border(.red)
    .frame(maxWidth: 100)
    .border(.blue)
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

100 points (the second line). A maximum is not only a limit. A frame with a maximum takes the width it is offered, up to that maximum — and it is offered the whole screen. .frame(minWidth: 100), the third line, also comes to 100, for the other reason: the text is smaller than the minimum.

Edge cases and tricky questions

10 questions of the kind an interviewer uses to find out whether you really know this — the corners, the traps, and the things that changed recently. Each has its source. Wherever the answer is what some code does, that code was compiled and run, and what you see under “Show the answer” is what it really did. Say your answer aloud before you open it.

Lesson 10 · question 1 of 10 · can a parent force a size?

Three children, each in a frame of 120 by 50: a short text, a color, and a long text with .fixedSize(). Watch, from this lesson's tutorial, prints what each is offered and what it takes. Predict the three lines.

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 40) {
            Watch("a text") {
                Text("Seattle")
            }
            .border(.red)
            .frame(width: 120, height: 50)
            .border(.blue)
            Watch("a color") {
                Color.yellow
            }
            .border(.red)
            .frame(width: 120, height: 50)
            .border(.blue)
            Watch("a fixed text") {
                Text("Pike Place Market on Saturday")
                    .fixedSize()
            }
            .border(.red)
            .frame(width: 120, height: 50)
            .border(.blue)
        }
    }
}
Show the answer
Screenshot of this code running
Blue: the three frames, all 120 by 50. Red: what each child tookScreenshot · iPhone 17, iOS 27.0

The console showed

Console
a text: offered 120 x 50, takes 53 x 20
a color: offered 120 x 50, takes 120 x 50
a fixed text: offered 120 x 50, takes 231 x 20

The same offer three times, and three answers: less, exactly, and more. That is the second of the three steps, and it is the one to be exact about in an interview. The interview-preparation repository's whole answer to "How does SwiftUI layout work?" is one line — "Parent proposes a size, child chooses its size, parent positions it" — and the word that carries the weight is chooses. WWDC19: "in SwiftUI there's no way to force a size on your child, the parent has to respect that choice."

Then say what a frame is for, since it cannot force. It does two things. It makes an offer, which most views accept: a color, a shape, a wrapping text, a resizable image. And it is itself a fixed size for its parent. The three blue boxes are 120 by 50 whatever is inside them, which is why the long text can stick out of the third without moving anything else on the screen.

The third child is not misbehaving. fixedSize() is a view's way of declining, and Apple describes it in exactly those terms: "the creation of a counter proposal to the view size proposed to a view by its parent."

Where this comes from: WWDC19: Building Custom Views with SwiftUI (2019 — still the clearest account of how layout works) · ProposedViewSize · frame(width:height:alignment:) · fixedSize() · Senior iOS Interview Preparation (GitHub) — WWDC19, “Building Custom Views with SwiftUI”. Apple, ProposedViewSize: "views choose their own size, but they do that in response to a size proposal from their parent view."

Lesson 10 · question 2 of 10 · a text cut short when it could have grown

A note in a box 240 points wide and 50 tall ends in "…", though the design lets the text run on below the box. One modifier makes the text take all the lines it needs. Which — and what was the text offered, before and after?

import SwiftUI

struct ContentView: View {
    let note = "Pike Place Market first thing on Saturday, before the crowds, and then the ferry to Bainbridge Island."

    var body: some View {
        VStack(spacing: 80) {
            Watch("as it comes") {
                Text(note)
            }
            .border(.red)
            .frame(width: 240, height: 50)
            .border(.blue)
            Watch("fixed vertically") {
                Text(note)
                    .fixedSize(horizontal: false, vertical: true)
            }
            .border(.red)
            .frame(width: 240, height: 50)
            .border(.blue)
        }
    }
}
Show the answer
Screenshot of this code running
The same note twice. Above, cut to the two lines that fit in 50 points. Below, all fourScreenshot · iPhone 17, iOS 27.0

The console showed

Console
as it comes: offered 240 x 50, takes 240 x 42
fixed vertically: offered 240 x 50, takes 240 x 86

.fixedSize(horizontal: false, vertical: true). Both were offered 240 by 50. The first did what a text does with too little room: two lines fit in 50 points, so it kept two and marked the cut with an ellipsis. Nothing limited its lines; the height did.

The second was offered the same, as the console shows, because Watch is outside the modifier. What the modifier does is to pass the offer on with its height taken out: "be as tall as you like." The text wrapped at 240 points as before and took the four lines it needed, 86 points, out through the bottom of the blue frame. Apple: it "fixes this view at its ideal size in the specified dimensions", and "this can result in the view exceeding the parent's bounds, which may or may not be the effect you want."

In real code the box is seldom a frame you wrote. It is a row, a cell or a card whose height comes from somewhere else. The symptom is the same — a text ending in "…" that had room to grow — and so is the cure. .lineLimit(nil) does not help: there was no line limit to remove.

Where this comes from: fixedSize(horizontal:vertical:) · fixedSize() — Apple, fixedSize(horizontal:vertical:).

Lesson 10 · question 3 of 10 · the button that will not grow

The design wants "Book this trip" as wide as the screen. A colleague adds .frame(maxWidth: .infinity) to the button, and the blue stays small. Why — and if the user taps the empty part of that wide frame, does the button fire?

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 24) {
            Button("Book this trip") { }
                .buttonStyle(.borderedProminent)
                .frame(maxWidth: .infinity)
                .border(.red)
            Button { } label: {
                Text("Book this trip")
                    .frame(maxWidth: .infinity)
            }
            .buttonStyle(.borderedProminent)
            .border(.red)
        }
        .padding()
    }
}
Show the answer
Screenshot of this code running
Above: the frame after the style. Below: the frame on the label. The red border is the frameScreenshot · iPhone 17, iOS 27.0

Read the first one from the top, as SwiftUI does: a button; a style that draws a blue capsule round its label; and then a frame. The frame really is as wide as the screen — that is the red border. Inside it is a finished button, exactly as large as its label needs, placed in the middle. A frame is a view. It makes an offer to its child, and a button that is offered more than it needs takes what it needs.

In the second the frame is on the label. Now the label itself is a view that takes all the width it is offered, and the style draws its capsule round that. The same modifier with the same value: the only difference is which view it wraps.

And the tap. The first arrangement, alone on the screen, was tapped twice: once in the empty part of the frame, well to the left of the blue, and once on the blue.

import SwiftUI

struct ContentView: View {
    var body: some View {
        Button("Book this trip") {
            print("booked")
        }
        .buttonStyle(.borderedProminent)
        .frame(maxWidth: .infinity)
        .border(.red)
        .padding()
    }
}
Screenshot of this code running
Screenshot · iPhone 17, iOS 27.0

The console showed, after both taps

Console
booked

One line, from the second tap. The frame is empty space round the button, not part of it: making a view's frame larger does not make the view larger, on the screen or under a finger. The next lesson has the tool for the other case, where a view should answer to taps over an area larger than what it draws.

Where this comes from: frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:) · WWDC19: Building Custom Views with SwiftUI (2019 — still the clearest account of how layout works) — WWDC19: "the frame is not a constraint in SwiftUI, it's just a view which you can think of like a picture frame."

Lesson 10 · question 4 of 10 · priority, or fixed size?

Two ways to keep "12 nights in November" whole in a row that is too narrow for both texts: .layoutPriority(1) and .fixedSize(). In a row 300 points wide they look alike. What is the difference — and what does each do in a row of 150?

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 24) {
            HStack {
                Text("San Pedro de Atacama, Chile")
                Text("12 nights in November").layoutPriority(1)
            }
            .frame(width: 300)
            .border(.blue)
            HStack {
                Text("San Pedro de Atacama, Chile")
                Text("12 nights in November").fixedSize()
            }
            .frame(width: 300)
            .border(.blue)
            HStack {
                Text("San Pedro de Atacama, Chile")
                Text("12 nights in November").layoutPriority(1)
            }
            .frame(width: 150)
            .border(.blue)
            HStack {
                Text("San Pedro de Atacama, Chile")
                Text("12 nights in November").fixedSize()
            }
            .frame(width: 150)
            .border(.blue)
        }
        .lineLimit(1)
    }
}
Show the answer
Screenshot of this code running
Rows 1 and 2 are 300 points wide, rows 3 and 4 are 150. Priority, then fixed size, each timeScreenshot · iPhone 17, iOS 27.0

At 300 points both do the job: the nights are whole, and the name gives way. They get there by different roads.

A priority changes the order of the offers. Apple: "A parent layout offers the child views with the highest layout priority all the space offered to the parent minus the minimum space required for all its lower-priority children." The text is still an ordinary text, which takes what it needs if that much is on offer and truncates if it is not.

A fixed size changes the answer. The text takes its ideal width whatever it is offered.

At 150 points, where the nights do not fit even alone, the roads part. With the priority (third row) the nights are offered nearly all of the row and truncate inside it, and the name is down to its first letter. With the fixed size (fourth row) the nights take their full width anyway and stick out of the blue border on both sides, and there is nothing left of the name at all.

So: layoutPriority when a view should win the argument and still behave, which is nearly always what you want. fixedSize when it must never be cut, and you would rather break the layout than the text. And for a one-line label that may shrink a little first, Apple offers a third tool: "Use the minimumScaleFactor(_:) modifier if the text you place in a view doesn't fit and it's okay if the text shrinks to accommodate."

Where this comes from: layoutPriority(_:) · fixedSize() · minimumScaleFactor(_:) · WWDC19: Building Custom Views with SwiftUI (2019 — still the clearest account of how layout works) — Apple, layoutPriority(_:).

Lesson 10 · question 5 of 10 · when nothing is offered

Inside a ScrollView, six views are each offered a width and no height at all. How tall is each: a text, a color, a GeometryReader, a Spacer, a capsule — and a text in .frame(maxWidth: .infinity, maxHeight: .infinity)?

import SwiftUI

struct ContentView: View {
    var body: some View {
        ScrollView {
            VStack(spacing: 12) {
                Watch("1 text") {
                    Text("Seattle")
                }
                .border(.red)
                Watch("2 color") {
                    Color.yellow
                }
                .border(.red)
                Watch("3 reader") {
                    GeometryReader { _ in Color.orange }
                }
                .border(.red)
                Watch("4 spacer") {
                    Spacer()
                }
                .border(.red)
                Watch("5 capsule") {
                    Capsule().fill(.teal)
                }
                .border(.red)
                Watch("6 wide frame") {
                    Text("Seattle")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .background(.green.opacity(0.3))
                }
                .border(.red)
            }
        }
    }
}
Show the answer
Screenshot of this code running
Six views in a scroll view: a word, four thin lines, and a word on a green bandScreenshot · iPhone 17, iOS 27.0

The console showed

Console
1 text: offered 402 x nothing, takes 53 x 20
2 color: offered 402 x nothing, takes 402 x 10
3 reader: offered 402 x nothing, takes 402 x 10
4 spacer: offered 402 x nothing, takes 402 x 8
5 capsule: offered 402 x nothing, takes 402 x 10
6 wide frame: offered 402 x nothing, takes 402 x 20

A scroll view cannot offer a height: its content is allowed to be as tall as it likes. So in that direction it offers nothing — in the API, nil, the unspecified proposal. Apple lists what a view does with each of the special offers: "The zero proposal; the view responds with its minimum size. The infinity proposal; the view responds with its maximum size. The unspecified proposal; the view responds with its ideal size."

Every view has an ideal size, and for the views that normally take everything it is small and arbitrary: 10 points for a color, a shape and a GeometryReader, and 8 for a spacer. For a text it is the size of its words. And the frame with maxHeight: .infinity has no offer to take all of, so it is as tall as its text: 20 points.

This one fact explains a family of bug reports. "My background color is a thin line in a scroll view." "My GeometryReader has no height." "My Spacer does nothing." "maxHeight: .infinity does not work." Each relies on an offer that a scroll view does not make. The cure is to say a height in some other way: .frame(height:), .aspectRatio(…), or .containerRelativeFrame(.vertical), which asks the scroll view itself how tall it is.

Where this comes from: ProposedViewSize · ScrollView · GeometryReader — Apple, ProposedViewSize.

Lesson 10 · question 6 of 10 · `GeometryReader`

"When would you use GeometryReader?" is a standard question. Start with what it does to a layout. A reader with one text in it, between two other texts: what is on the screen?

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Text("above").border(.red)
            GeometryReader { proxy in
                Text("\(Int(proxy.size.width)) x \(Int(proxy.size.height))")
                    .border(.red)
            }
            .background(.yellow.opacity(0.4))
            Text("below").border(.red)
        }
    }
}
Show the answer
Screenshot of this code running
The yellow is the reader. Its text reports the size it was givenScreenshot · iPhone 17, iOS 27.0

The reader took everything that was left, 402 by 720, and pushed "below" to the bottom of the screen. And its text is not in the middle of that space but in the top leading corner. Those are the two surprises. Apple's reference: "This view returns a flexible preferred size to its parent layout" — in practice, all of it — and, unlike a stack or a frame, a reader does not center what is in it.

A third surprise, when the same thing is put in a scroll view:

Screenshot of this code running
The same three views inside a ScrollView: the reader is 10 points tall, and its text hangs out of itScreenshot · iPhone 17, iOS 27.0

Offered no height, the reader falls back on its ideal size, which is 10 points. A reader's size depends entirely on what it is offered, which is why it so often "works in one place and breaks in another".

Now the question. Hacking with Swift's suggested answer: "GeometryReader allows us to read the size and location of a view, which means we can create proportional layouts or create adaptive modifiers that change their values as a view moves around the screen. For bonus points you might want to add that GeometryReader is frequently over-used."

The bonus points are the part to prepare, because most of its old jobs have better tools now.

You wantUse
A fraction of the screen, or of a scroll viewcontainerRelativeFrame
Equal shares of a row.frame(maxWidth: .infinity) on each
One arrangement if it fits, another if notViewThatFits
To know when a view's size or position changesonGeometryChange(for:of:action:), which does not change the layout
An effect that depends on where a view isvisualEffect, which hands you a proxy without a reader
A container with rules of its ownthe Layout protocol (the next lesson)

What is left is what this lesson's tutorial used it for: drawing something whose shape is computed from the space, like the bar. Give it an explicit size when you do. The interview-preparation repository lists "deep GeometryReader / nested layouts" among the things that make scrolling slow.

Where this comes from: Hacking with Swift interview question: When would you use GeometryReader? · GeometryReader · onGeometryChange(for:of:action:) · visualEffect(_:) · Senior iOS Interview Preparation (GitHub) — Hacking with Swift: “When would you use GeometryReader?”

Lesson 10 · question 7 of 10 · three blue backgrounds

Three ways to give a screen a pale blue background: .background(.blue.opacity(0.3)); .background { Color.blue.opacity(0.3) }; and the second again, with .ignoresSafeArea() on the color. The view is the same each time: a text in a frame that fills the screen. Which of the three reach under the status bar and the home indicator?

Show the answer
import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Trips")
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.blue.opacity(0.3))
    }
}
Screenshot of this code running
1. .background(.blue.opacity(0.3)), a style: the whole screenScreenshot · iPhone 17, iOS 27.0

The first reaches the edges. It is the form of background that takes a style, and Apple says of it: "By default, the background ignores safe area insets on all edges, but you can provide a specific set of edges to ignore, or an empty set to respect safe area insets on all edges."

.background {
    Color.blue.opacity(0.3)
}
Screenshot of this code running
2. .background { Color.blue.opacity(0.3) }, a view: the safe area onlyScreenshot · iPhone 17, iOS 27.0

The second does not. With braces, the background is a view, and a view is laid out like any other: inside the safe area. "By default, the SwiftUI layout system sizes and positions views to avoid certain safe areas." The only difference between the first call and the second is a pair of braces.

.background {
    Color.blue.opacity(0.3)
        .ignoresSafeArea()
}
Screenshot of this code running
3. The same view with .ignoresSafeArea(): the whole screen againScreenshot · iPhone 17, iOS 27.0

The third puts it right by saying so, on the color and on nothing else.

That is the rule to take away: ignore the safe area for backgrounds, never for content. ignoresSafeArea() on the stack that holds a screen's text is the quick fix that puts a title under the clock.

Where this comes from: background(_:ignoresSafeAreaEdges:) · ignoresSafeArea(_:edges:) — Apple, background(_:ignoresSafeAreaEdges:) and ignoresSafeArea(_:edges:).

Lesson 10 · question 8 of 10 · `offset` is not layout

Five texts in a column with no spacing. The second has .offset(y: 30) and the fourth .padding(.top, 30); each of the two has a blue border, added after the modifier. Where does everything end up?

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 0) {
            Text("one").border(.red)
            Text("offset").offset(y: 30).border(.blue)
            Text("three").border(.red)
            Text("padding").padding(.top, 30).border(.blue)
            Text("five").border(.red)
        }
    }
}
Show the answer
Screenshot of this code running
The word "offset" has moved onto "three". Its border, and everything else, has notScreenshot · iPhone 17, iOS 27.0

The word "offset" is drawn 30 points lower, on top of "three". Its blue border is still between "one" and "three", round an empty space, and "three" has not moved. Apple: "The original dimensions of the view aren't changed by offsetting the contents". Layout runs first, with the view where it was. The offset is applied afterward, when the view is drawn. The border came after the offset in the code, so it wraps the view as layout sees it: unmoved.

padding is layout. The fourth view really is 30 points taller, its border shows that, and "five" has moved down to make room.

So an offset is for moving how something looks without disturbing its neighbors — a nudge, a badge that overhangs a corner, the travel of an animation. It is also a way to make two views overlap by accident, because nothing makes room for where the view now appears. If the neighbors should make room, it was padding, a spacer or an alignment that you wanted.

Where this comes from: offset(x:y:) · Making fine adjustments to a view’s position — Apple, offset(x:y:).

Lesson 10 · question 9 of 10 · `containerRelativeFrame`: relative to what?

A panel with 40 points of padding holds three bars. The first should be half as wide as the panel, and uses containerRelativeFrame(.horizontal) { width, _ in width / 2 }. The second uses containerRelativeFrame(.horizontal) alone. The third uses .frame(maxWidth: .infinity). How wide is each, on a screen 402 points wide?

VStack {
    Color.teal
        .frame(height: 50)
        .containerRelativeFrame(.horizontal) { width, _ in width / 2 }
        .measured("1 half of the container")
    Color.teal
        .frame(height: 50)
        .containerRelativeFrame(.horizontal)
        .measured("2 the whole container")
    Color.indigo
        .frame(height: 50)
        .frame(maxWidth: .infinity)
        .measured("3 maxWidth: .infinity")
}
.padding(40)
.border(.red)
Show the answer
Screenshot of this code running
The red border is the padded panel. Only the third bar respects itScreenshot · iPhone 17, iOS 27.0

The widths, printed by the app (measured records a view's size, and they are printed a second later)

Console
1 half of the container: 201 points wide
2 the whole container: 402 points wide
3 maxWidth: .infinity: 322 points wide

Half of the screen, not half of the panel. "The nearest container" does not mean the parent. Apple lists what counts as one: "The window presenting a view on iPadOS or macOS, or the screen of a device on iOS", a column of a NavigationSplitView, a NavigationStack, a tab of a TabView, and "a scrollable view like ScrollView or List". A stack with padding is not on the list.

So the second bar, which asks for the whole container, is 402 points wide and runs straight through the panel's padding, from one edge of the screen to the other. The third is an ordinary flexible frame. It takes what it is offered, which is the 322 points inside the padding.

The modifier is for the cases where the offer is no use, and a scroll view is the first of them. There the arithmetic matters, and the padding does too:

import SwiftUI

enum Measured {
    static var sizes: [String: CGSize] = [:]

    static func report() async {
        try? await Task.sleep(for: .seconds(1))
        for name in sizes.keys.sorted() {
            print("\(name): \(Double(sizes[name]!.width).formatted(.number.precision(.fractionLength(0...1)))) points wide")
        }
    }
}

extension View {
    /// Remembers the size this view ended up with, under a name.
    func measured(_ name: String) -> some View {
        onGeometryChange(for: CGSize.self) { proxy in
            proxy.size
        } action: { size in
            Measured.sizes[name] = size
        }
    }
}

struct ContentView: View {
    var body: some View {
        VStack(spacing: 20) {
            ScrollView(.horizontal) {
                HStack(spacing: 12) {
                    ForEach(0..<3, id: \.self) { _ in
                        Color.yellow
                            .frame(height: 60)
                            .containerRelativeFrame(.horizontal, count: 5, span: 4, spacing: 12)
                            .measured("1 with safeAreaPadding on the scroll view")
                    }
                }
            }
            .safeAreaPadding(.horizontal)
            ScrollView(.horizontal) {
                HStack(spacing: 12) {
                    ForEach(0..<3, id: \.self) { _ in
                        Color.orange
                            .frame(height: 60)
                            .containerRelativeFrame(.horizontal, count: 5, span: 4, spacing: 12)
                            .measured("2 with padding on the content")
                    }
                }
                .padding(.horizontal)
            }
        }
        .task { await Measured.report() }
    }
}

The console showed

Console
1 with safeAreaPadding on the scroll view: 293.6 points wide
2 with padding on the content: 319.2 points wide
Screenshot of this code running
The same count: 5, span: 4, spacing: 12 twice: 293.6 points with safeAreaPadding, 319.2 withoutScreenshot · iPhone 17, iOS 27.0

Apple gives the formula: the container's width, less the gaps between columns, divided by count, times span, plus the gaps that the span covers. With nothing subtracted that is (402 − 48) ÷ 5 × 4 + 36 = 319.2. And "the size provided to this modifier is the size of a container … subtracting any safe area insets". safeAreaPadding adds 16 points a side to the scroll view's safe area, so the container counts as 370: (370 − 48) ÷ 5 × 4 + 36 = 293.6. Ordinary padding on the content is not a safe area, and changes nothing.

For a fraction of a parent this is the wrong tool: use a GeometryReader, or a layout of your own.

Where this comes from: containerRelativeFrame(_:alignment:) · containerRelativeFrame(_:count:span:spacing:alignment:) — Apple, containerRelativeFrame(_:alignment:).

Lesson 10 · question 10 of 10 · what a minimum and a maximum really do

One short text, a little over 53 points wide, on a screen 402 points wide, in six different frames. How wide is each frame?

Text("Seattle").border(.red)
    .frame(minWidth: 100).border(.blue)
    .measured("1 minWidth: 100")
Text("Seattle").border(.red)
    .frame(maxWidth: 100).border(.blue)
    .measured("2 maxWidth: 100")
Text("Seattle").border(.red)
    .frame(maxWidth: .infinity).border(.blue)
    .measured("3 maxWidth: .infinity")
Text("Seattle").border(.red)
    .frame(minWidth: 100, maxWidth: 200).border(.blue)
    .measured("4 minWidth: 100, maxWidth: 200")
Text("Seattle").border(.red)
    .frame(idealWidth: 150).border(.blue)
    .measured("5 idealWidth: 150")
Text("Seattle").border(.red)
    .frame(idealWidth: 150).fixedSize().border(.blue)
    .measured("6 idealWidth: 150, then fixedSize()")
Show the answer
Screenshot of this code running
Red: the text, the same size every time. Blue: the frameScreenshot · iPhone 17, iOS 27.0

The widths, printed by the app

Console
1 minWidth: 100: 100 points wide
2 maxWidth: 100: 100 points wide
3 maxWidth: .infinity: 402 points wide
4 minWidth: 100, maxWidth: 200: 200 points wide
5 idealWidth: 150: 53.3 points wide
6 idealWidth: 150, then fixedSize(): 150 points wide

The second line is the one that people get wrong. A maximum is not only a limit on the child. A frame with a maximum is a view that takes what it is offered, up to the maximum: it was offered 402, so it is 100. That is also all there is to maxWidth: .infinity in the third line: the same rule with no upper end, so the frame is as wide as the offer. Nothing about infinity is special.

Apple's reference states the rule for one constraint at a time. With a maximum, when "the size proposed for the frame by the parent is greater than the size of this view", the frame is "the proposed size, clamped to that maximum." With a minimum, a frame that is offered less than its child wants takes the offer, but never less than the minimum. And with both, as in the fourth line, the frame "unconditionally adopts the size proposed for it, clamped to the constraints": offered 402, it is 200.

The first line shows the plain use of a minimum: a frame is never smaller than it, whatever is inside. That is the 44-point tap target.

The ideal width, in the fifth line, did nothing at all. An ideal size is what a view takes when it is offered nothing — and here it was offered the screen. Take the offer away, with .fixedSize() or by putting the view in a scroll view, and the ideal size is what you get: 150, in the last line. This is how a view of your own can say what size it would like to be when nobody tells it.

Where this comes from: frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:) · fixedSize() — Apple, frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:).

Further reading