SwiftUI layout bugs are uniquely frustrating because the framework does so much for you that when something goes wrong, it feels invisible. There is no frame inspector like in UIKit. No red "misplaced view" warning. Just a screen that looks wrong and no obvious way to ask why.
I want to share a handful of techniques that have saved me countless hours. None of them are fancy. They will not impress anyone at a conference talk. But they work, and once you start using them, you will wonder why you ever relied on trial and error.
First, Understand What You Are Actually Debugging
Before jumping into tricks, it helps to internalize what SwiftUI is doing under the hood. The entire layout system is a three-step negotiation:
- The parent proposes a size to the child.
- The child decides its own size (based on the proposal, its content, or its own rules).
- The parent positions the child within its coordinate space.
That is the whole thing. Every layout bug you will ever encounter is a breakdown in one of these three steps. Either the proposal is not what you expected, the child is choosing a surprising size, or the positioning is off.
The problem is that none of this is visible by default. So the game is: how do you make SwiftUI show its work?
Trick 1: The Debug Background
The simplest tool, and still the one I reach for first. Slap a semi-transparent background on any view to see its actual bounds.
.background(Color.red.opacity(0.3))
This sounds almost insultingly basic, but it is powerful because it shows you reality instead of what you think is happening. If a view's background is smaller than expected, the parent is constraining it. If it is larger, the child is being greedy. If the position is wrong but the size is right, you have an alignment issue.
Make it a reusable modifier so you can tag views quickly with different colors:
extension View {
func debugBorder(_ color: Color = .red) -> some View {
self.border(color, width: 1)
}
}
Use .debugBorder(.red) on one view, .debugBorder(.blue) on its sibling, and suddenly you can see exactly how they share space. This is the foundation, but it is just the start.
Trick 2: Print the Proposed Size with GeometryReader (Without Breaking Layout)
A colored background tells you the result of the layout negotiation, but sometimes you need to know what was proposed. What size did the parent actually offer?
The common mistake is wrapping your view in a GeometryReader to find out, but that changes the layout because GeometryReader is greedy and expands to fill all available space. You end up debugging a different layout than the one you started with.
Instead, use it as a background:
.background(
GeometryReader { geo in
Color.clear.onAppear {
print("Proposed size: \(geo.size)")
}
}
)
This reads the geometry without affecting it. The Color.clear occupies no space visually, and the GeometryReader as a background inherits the size of the view it is attached to rather than greedily expanding. You get the measurement printed to the console without disturbing anything.
This is particularly useful when you suspect a parent is offering less space than you expect, say a ScrollView or a NavigationStack silently eating some of your available width or height.
Trick 3: The Overlay Size Label
Printing to the console works, but switching between the simulator and the debug console gets tiring fast. Sometimes you want to see the dimensions right there on the view itself.
extension View {
func debugSize() -> some View {
self.overlay(
GeometryReader { geo in
Text("\(Int(geo.size.width))x\(Int(geo.size.height))")
.font(.caption2)
.foregroundColor(.white)
.background(Color.black.opacity(0.7))
}
)
}
}
Now slap .debugSize() on any view and its pixel dimensions appear directly on screen. No console hunting. No mental gymnastics. When you have three views in an HStack and you cannot figure out why the spacing is off, put .debugSize() on all three and the numbers will tell the story instantly.
Trick 4: Use .layoutPriority() to Understand Who Wins
Here is a scenario that confuses almost every SwiftUI developer at some point. You have two Text views in an HStack, and one of them is getting truncated even though there seems to be enough room. Why?
The answer is usually layout priority. When SwiftUI does not have enough space for all children, it has to decide who gets their preferred size and who compromises. By default, all views have a layout priority of zero, and SwiftUI uses its own heuristics to break the tie.
Instead of guessing, use .layoutPriority() as a debugging tool:
HStack {
Text("A really long title that matters")
.layoutPriority(1)
Text("Details")
}
Giving a view a higher priority tells SwiftUI: "This one gets its preferred size first. Everyone else can have what is left." By experimenting with priority values, you can quickly figure out which views are competing for space and how SwiftUI is resolving the conflict.
Even if you remove the priority later, the act of adding it teaches you what the underlying problem was. It is debugging by perturbation: poke the system in a specific way and watch how it responds.
Trick 5: Replace Views with Fixed-Size Rectangles
When your layout is really confusing and you cannot tell which view is causing the problem, strip everything down to colored rectangles.
HStack {
Rectangle().fill(.red).frame(width: 100, height: 50)
Rectangle().fill(.blue).frame(width: 100, height: 50)
Rectangle().fill(.green).frame(width: 100, height: 50)
}
This removes all the complexity of text sizing, image intrinsic sizes, and content-dependent behavior. If the layout looks correct with rectangles, the problem is not your structure, it is how a specific child view is sizing itself. Now swap the rectangles back one at a time until the layout breaks. The view you just swapped in is your culprit.
This is basically the binary search approach to debugging, applied to UI. It feels crude, but it isolates problems fast, especially in complex layouts where five or six things might be interacting in unexpected ways.
Trick 6: The .fixedSize() Test
This one is more of a diagnostic question than a fix, but it is incredibly revealing.
If a view is not the size you expect, try adding .fixedSize() to it. This tells the view to ignore the parent's proposal and use its own ideal size.
If the view suddenly becomes the size you wanted, the problem is upstream: the parent is constraining the child. If the view becomes ridiculously large, the child's ideal size is unbounded, and you actually need that parental constraint, you just need a different one.
.fixedSize() also accepts parameters for specific axes:
.fixedSize(horizontal: true, vertical: false)
This is especially useful for Text views that are wrapping when you do not want them to, or not wrapping when you do. Adding .fixedSize(horizontal: true, vertical: false) tells the text "take all the horizontal space you want" which reveals whether the wrapping is caused by a width constraint you did not know about.
Again, you might remove this modifier after debugging. The point is what it teaches you about the negotiation.
Trick 7: Watch the Console for Layout Warnings
SwiftUI actually does print layout warnings sometimes, but most developers either do not notice them or filter them out as noise.
Look for messages like:
- "Unable to simultaneously satisfy constraints" (more common in UIKit interop, but it shows up)
- "Bound preference ... tried to update multiple times per frame" (often a sign of a layout feedback loop)
- "onChange(of:) action tried to update multiple times per frame" (your layout is oscillating)
That last one is particularly sneaky. It means a view's size depends on some state that changes when the view resizes, which causes it to resize again, which changes the state again. An infinite loop that SwiftUI catches and breaks, but your layout still ends up wrong.
If you see these warnings, do not ignore them. They are SwiftUI telling you exactly what is broken.
Trick 8: Use _printChanges() in the Body
When you suspect a view is re-rendering too often and causing layout instability, drop this inside the body:
var body: some View {
let _ = Self._printChanges()
// rest of your view
}
This prints to the console every time the view's body is recomputed, along with why it changed (which @State, @Binding, or @ObservedObject triggered it). It is not a layout tool directly, but layout bugs are often caused by unexpected re-renders: a view's size changes because its content changed because some state updated that you did not expect.
If you see a view printing changes dozens of times per second, you have found a feedback loop, and that is almost certainly related to your layout issue.
Putting It All Together
Here is the mental model I use when a layout goes wrong:
Step 1: Add debug backgrounds or .debugSize() to the view that looks wrong and its immediate parent. This tells you the actual sizes. Ninety percent of the time, the problem is obvious at this point.
Step 2: If sizes look wrong, use the GeometryReader-as-background trick to see what the parent proposed. This tells you whether the parent is the problem.
Step 3: If you cannot tell which view is causing trouble in a complex layout, replace views with colored rectangles and swap them back one at a time.
Step 4: Try .fixedSize() on the problematic view. If it fixes the size, the parent is constraining it. If it makes things worse, the child is the problem.
Step 5: Check the console for layout warnings and use _printChanges() to look for unexpected re-renders.
This is a systematic process, not guesswork. Each step narrows the problem until the fix becomes obvious.
Summary
Next time your layout looks wrong, give yourself few minutes with debug backgrounds and size overlays before changing a single line of layout code. You will be surprised how often the fix is obvious once you can actually see what is going on.
Top comments (0)