Developing with SwiftUI often feels like magic. You write a few lines of declarative code, and voilà, a complex and reactive interface appears. But, as any iOS, macOS, or watchOS developer knows, when the magic breaks, the dream can quickly turn into a nightmare.
Unlike UIKit, where the imperative flow allowed us to follow execution line-by-line with ease, SwiftUI operates under a “diffing” engine and opaque rendering system. Why is my view redrawing 50 times? Why is this animation stuttering? Why isn’t the state updating?
In this definitive tutorial, we will stop guessing and start investigating. We will learn how to use Xcode’s advanced tools to dissect SwiftUI applications, ranging from strategic print statements to deep analysis with LLDB and Instruments.
1. The Mindset Shift: Declarative vs. Imperative
Before opening the debugger, we must understand the problem. In UIKit, if a button didn’t change color, you placed a breakpoint in the buttonTapped function and checked if the line button.backgroundColor = .red was executed.
In SwiftUI, you don’t change the color. You declare that the color is red based on a state. If it doesn’t change, the issue could be:
- The state didn’t change.
- The state changed, but SwiftUI decided the view didn’t need redrawing.
- The view redrew, but a parent layer is enforcing a different style.
- The view’s identity (its internal ID) changed, resetting its state.
To diagnose this, we need tools that allow us to “see” the view’s lifecycle.
2. Basic Debugging: When print isn’t enough
The basic instinct is to clutter the code with print("I got here"). But in SwiftUI, you can’t place a print in the middle of a @ViewBuilder because print returns Void and the builder expects a View.
The let _ = ... Trick
You can inject side effects into a view’s evaluation by assigning the result of a print to an anonymous variable inside a code block, or more easily, by using an extension.
However, the cleanest “inline” method without extensions is this:
var body: some View {
VStack {
let _ = print("⚡️ VStack is being evaluated")
Text("Hello World")
}
}The debugPrint Modifier
To keep your code clean, I recommend adding this ViewModifier to your development toolkit (Utils):
extension View {
func debugAction(_ closure: () -> Void) -> some View {
closure()
return self
}
func debugPrint(_ value: Any) -> some View {
debugAction { print(value) }
}
}Usage:
Text("Counter: \(count)")
.debugPrint("The text has been redrawn with value: \(count)")This confirms if the view is re-evaluating when you think it should (or when it shouldn’t).
3. The Secret Weapon: Self._printChanges()
If you only take one thing away from this tutorial, let it be this. Apple included a hidden static method (later unofficially acknowledged for debugging) that tells you exactly why a view was redrawn.
Implementation:
struct MyHeavyView: View {
@State var counter = 0
var body: some View {
let _ = Self._printChanges() // <--- MAGIC HERE
Button("Increment: \(counter)") {
counter += 1
}
}
}What you will see in the Xcode console:
MyHeavyView: @self, @identity, _counter changed.
This is pure gold. It tells you:
- _variableName changed: That specific state property changed.
- @self changed: The view struct changed (likely because the parent passed new parameters in the
init). - @identity changed: The view’s identity changed (SwiftUI thinks it’s a brand new view, losing internal state).
If your view updates and you see @self changed but no relevant data has changed, you are suffering from “over-invalidation” (excessive redrawing), which kills battery life and performance in large lists.
4. Breakpoints in a Declarative World
Traditional breakpoints work, but they often stop execution in unhelpful places deep within the SwiftUI framework. Here are two techniques to use them better.
Column Breakpoints
Sometimes you have multiple statements or modifiers chained on a single line.
Text("Hello").padding().background(Color.red)If you place a breakpoint on the line number, it stops at the beginning. But what if you want to stop just before the .background is applied?
- Right-click (or Cmd+Click) on the
.backgroundmodifier. - Select “Set Column Breakpoint”. Xcode will pause execution exactly at that step in the view construction pipeline.
Log Breakpoints (Don’t Stop, Just Report)
Pausing the app breaks the flow of gestures and animations. Sometimes it’s better to use the breakpoint to inject logs without recompiling the code.
- Set a blue breakpoint.
- Double-click to edit it.
- Action: Select “Log Message”.
- Type:
The offset value is: @offset@. - Check the box “Automatically continue after evaluating actions”.
Now you have real-time logs without dirtying your source code with print().
5. View Hierarchy Debugger: X-Ray Vision
Have you ever had a button that doesn’t respond to clicks? 99% of the time, it’s because there is a transparent view (an invisible frame or a Color.clear) sitting on top of it, stealing the touch event.
To diagnose this:
- Run the app.
- Go to the bottom bar in Xcode (while the app is running).
- Click the icon that looks like three stacked rectangles (Debug View Hierarchy).
Xcode will freeze the app and show you a 3D exploded view of your layers.
What to look for:
- Z-Index: Rotate the 3D model sideways. Is your button physically behind another layer?
- Giant Frames: Click on empty spaces. Sometimes a
Spacer()or an unboundedVStackis occupying the whole screen and intercepting touches. - Identify Views: On the left panel, you’ll see the hierarchy. SwiftUI tends to create many intermediate views (
ModifiedContent,HostingController). Look for the names of your Structs to orient yourself.
6. Debugging State with LLDB
When execution pauses (due to a breakpoint or a crash), we enter the domain of LLDB (the command console at the bottom).
In UIKit, we used po view and saw a lot of info. In SwiftUI, po self inside the body sometimes returns unreadable structures due to nested generic types (VStack<TupleView<...>>).
Useful commands for SwiftUI:
pvspo: Sometimespo(Print Object) tries to get thedebugDescriptionand fails or gives too much info. Try usingp(Print) to get the raw data structure of the current struct.- Printing State: If you have
@State var isActive: Bool, inside LLDB you can write:
p _isActiveNote the underscore. This accesses the Property Wrapper itself, allowing you to see the actual stored value.
Modifying State in Real-Time (Expression): You can force UI changes without restarting the app.
expression self.counter = 99- Then, resume execution. On the next run loop update, SwiftUI should catch the change (although sometimes it requires forcing an interaction to “wake up” the system and redraw).
7. Instruments: The Vital Signs Monitor
If your problem isn’t logic, but rather that the app “hangs” or scrolling is sluggish, Xcode Instruments is the solution.
For SwiftUI, there is a specific template: SwiftUI Analysis (in recent Xcode versions) or use the Time Profiler.
Detecting Hangs
- Press
Cmd + Iin Xcode to open Instruments (Profile). - Choose Time Profiler.
- Use the app and trigger the slowness issue.
- Stop recording.
- In the timeline, look for large spikes in CPU usage.
- Expand the Call Tree. Pro Tip: Enable “Invert Call Tree” and “Hide System Libraries” in the right panel.
If you see body being called thousands of times, or heavy mathematical calculations inside the body getter, you’ve found your culprit. Golden Rule: The body property must be computationally cheap. If you need to filter an array of 10,000 items, don’t do it inside the body; do it in a ViewModel or a .task.
8. The Dreaded “Purple Warnings” (Runtime Issues)
Xcode has vastly improved at detecting runtime errors. These appear as purple warnings in the editor.
“Modifying state during view update, this will cause undefined behavior.”
This is the most common error in SwiftUI. It happens when you change a @State or @Published directly inside a view’s body, without wrapping it in an event.
The Error:
var body: some View {
if data == nil {
model.loadData() // ❌ Error: This triggers a state change while the view is drawing
}
return Text("Loading...")
}The Solution: Move that logic to a modifier designed for side effects or asynchrony.
var body: some View {
Text("Loading...")
.onAppear { // ✅ Correct
model.loadData()
}
// Or even better in iOS 15+
.task {
await model.loadData()
}
}“Main Thread Checker”
If you try to update the UI from a background thread, you will have trouble. SwiftUI usually handles this better than UIKit, but if you use ViewModels with Combine or async/await, ensure that @Published variable updates happen on the MainActor.
@MainActor // ✅ Ensures the entire class runs on the main thread
class MyViewModel: ObservableObject { ... }9. Previews: Your Testing Lab
Many people use Previews only for design, but they are an excellent debugging tool.
Preview as an Isolated Test Environment
If a view fails in the full app, isolate the view in a Preview.
- Create a Preview with “mock” data that reproduces the error.
- Use the “Play” button (Live Preview) to interact.
- Right-click on the Play button in the Preview and select “Debug Preview”. This attaches the LLDB debugger to the Preview process. Now your breakpoints will work inside the Preview without having to launch the full simulator.
Conclusion: Debugging is Understanding
Debugging in SwiftUI requires stopping thinking in “steps” and starting to think in “states” and “reactions”.
The tools are there:
- Use
_printChanges()to understand redrawing. - Use View Hierarchy for visual and touch issues.
- Use Column Breakpoints to inspect modifiers.
- Pay attention to Xcode’s purple warnings.
By mastering these techniques, you will stop fighting the framework and start flowing with it. The next time your UI doesn’t do what you expect, don’t get frustrated; smile, open the console, and ask your code: “Why have you changed?”
If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.