Debugging SwiftUI views is an essential skill when writing dynamic views with several redrawing triggers. Property wrappers like @State
and @ObservedObject
will redraw your view based on a changed value. This is often expected behavior, and things look like they should. However, in so-called Massive SwiftUI Views (MSV), there could be many different triggers causing your views to redraw unexpectedly.
I made up the MSV, but you probably get my point. In UIKit, we used to have so-called Massive View Controllers, which had too many responsibilities. You’ll learn through this article why it’s essential to prevent the same from happening when writing dynamic SwiftUI views. Let’s dive in!
What is a dynamic SwiftUI View?
A dynamic SwiftUI view redraws as a result of a changed observed property. An example could be a timer count view that updates a label with an updated count integer:
struct TimerCountView: View {
@State var count = 0
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
Text("Count is now: \(count)!")
.onReceive(timer) { input in
count += 1
}
}
}
Every time the timer fires, the count will go up. Our view redraws due to the @State
attribute attached to the count property. The TimerCountView
is dynamic since its contents can change.
It’s good to look at a static view example to fully understand what I mean by dynamic. In the following view, we have a static text label that uses the input title string:
struct ArticleView: View {
let title: String
var body: some View {
Text(title)
}
}
You could argue whether it’s worth creating a custom view for this example, but it does demonstrate a simple example of a static view. Since the title
property is a static let without any attributes, we can assume this view will not change. Therefore, we can call this a static view. Debugging SwiftUI views that are static is likely only sometimes needed.
The problem of a Massive SwiftUI View
A SwiftUI view with many redraw triggers can be a pain. Each @State
, @ObservedObject
, or other triggers can cause your view to redraw and influence dynamics like a running animation. Knowing how to debug a SwiftUI view becomes especially handy in these cases.
For example, we could introduce an animated button known from Looki into our timer view. The animation starts on appear and rotates the button back and forth:
struct TimerCountView: View {
@State var count = 0
@State var animateButton = true
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Text("Count is now: \(count)!")
.onReceive(timer) { input in
count += 1
}
Button {
} label: {
Text("SAVE")
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundColor(.white)
.padding(.vertical, 6)
.padding(.horizontal, 80)
.background(.red)
.cornerRadius(50)
.shadow(color: .secondary, radius: 1, x: 0, y: 5)
}.rotationEffect(Angle(degrees: animateButton ? Double.random(in: -8.0...1.5) : Double.random(in: 0.5...16)))
}.onAppear {
withAnimation(.easeInOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) {
animateButton.toggle()
}
}
}
}
Since both the timer and the animation are triggering a redraw of the same TimerCountView
, our resulting animation is not what we expected:
The random value for our rotation effect is changed on every view redraw. The timer and our boolean toggle trigger a redraw, causing the button to jump instead of animating smoothly.
The above example shows what a view with multiple states can cause, while our example was still relatively small. A view with more triggers can cause several of these side effects, making it hard to debug which trigger caused an issue.
Before I explain how to solve this, I’ll demonstrate a few techniques you can apply to discover the cause of a SwiftUI View redraw.
Using LLDB to debug a change
LLDB is our debugging tool available inside the Xcode console. It allows us to print objects using po object
and find out the state while our application is paused by, for example, a breakpoint.
Swift provides us with a private static method Self._printChanges()
that prints out the trigger of a redraw. We can use it by setting a breakpoint in our SwiftUI body and typing po Self._printChanges()
inside the console:
As you can see, the console tells us the _count
property changed. Our SwiftUI view redraws since we observed our count property as a state value.
To thoroughly verify our count property is causing animation issues, we could temporarily turn off the timer and rerun our app. You’ll see a smooth animation that does not cause any problems anymore.
This was just a simple debugging example. Using Self._printChanges()
can be helpful in cases where you want to find out which state property triggered a redraw.
Using _logChanges in Xcode 15.1 and up
Xcode 15.1 introduced a new debugging method similar to Self._printChanges
that allows you to debug SwiftUI views:
var body: some View {
#if DEBUG
Self._logChanges()
#endif
return VStack {
/// ...
}
}
This method works similarly and logs the names of the changed dynamic properties that caused the result of body
to be refreshed. The information is logged at the info level using the com.apple.SwiftUI
subsystem and category “Changed Body Properties” which is a major benefit over Self._printChanges
as you can benefit from Xcode 15’s new debugging console.
Solving redraw issues in SwiftUI
Before diving into other debugging techniques, it’s good to explain how to solve the above issue in SwiftUI. The LLDB debugging technique gave us enough to work with for now, and we should be able to extract the timer away from the button animation.
We can solve our issue by isolating redraw triggers into single responsible views. By isolating triggers, we will only redraw relevant views. In our case, we want to separate the button animation only to redraw the button when our animateButton
boolean toggles:
struct TimerCountFixedView: View {
@State var count = 0
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Text("Count is now: \(count)!")
.onReceive(timer) { input in
count += 1
}
AnimatedButton()
}
}
}
struct AnimatedButton: View {
@State var animateButton = true
var body: some View {
Button {
} label: {
Text("SAVE")
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundColor(.white)
.padding(.vertical, 6)
.padding(.horizontal, 80)
.background(.red)
.cornerRadius(50)
.shadow(color: .secondary, radius: 1, x: 0, y: 5)
}.rotationEffect(Angle(degrees: animateButton ? Double.random(in: -8.0...1.5) : Double.random(in: 0.5...16))).onAppear {
withAnimation(.easeInOut(duration: 1).delay(0.5).repeatForever(autoreverses: true)) {
animateButton.toggle()
}
}
}
}
Running the app with the above code will show a perfectly smooth animation while the count is still updating:
The timer no longer changes the rotation effect random value since SwiftUI is smart enough not to redraw our button for a count change. Another benefit of isolating our code into a separate AnimatedButton
view is that we can reuse this button anywhere in our app.
The view examples in this article are still relatively small. When working on an actual project, you can quickly end up with a view having lots of responsibilities and triggers. What works for me is to be aware of situations in which a custom view makes more sense. Whenever I’m creating a view builder property like:
var animatedButton: some View {
// .. define button
}
I ask myself the question of whether it makes more sense to instead create a:
struct AnimatedButton: View {
// .. define button
}
By applying this mindset, you’ll lower the chances of running into animation issues in the first place.
Debugging changes using code
Now that we know how debugging SwiftUI views works using the Self._logChanges()
method, we can look into other valuable ways of using this method. Setting a breakpoint like the previous example only works when you know which view is causing problems. There could be cases where you have multiple affected views since they all monitor the same observed object.
Using code could be a solution since it does not require manually entering lldb commands after a breakpoint hits. The code change speeds up debug processes since it constantly runs while your views are redrawn. We can use this technique as follows:
var body: some View {
#if DEBUG
Self._logChanges()
#endif
return VStack {
// .. other changes
}
}
The above code changes will print out any changes to our view inside the console:
TimerCountView: @self, @identity, _count, _animateButton changed.
TimerCountView: _animateButton changed.
TimerCountView: _count changed.
TimerCountView: _count changed.
You might notice that we’re getting a few new statements logged inside the console. The @self
and @identity
are new, and you might wonder what they mean. Looking at the documentation of the _printChanges method we’ll get an explanation:
When called within an invocation of
body
of a view of this type, prints the names of the changed dynamic properties that caused the result ofbody
to need to be refreshed. As well as the physical property names, “@self” is used to mark that the view value itself has changed, and “@identity” to mark that the identity of the view has changed (i.e. that the persistent data associated with the view has been recycled for a new instance of the same type).
@self
and @identity
are followed by the properties that changed. The above example shows that both _count
and _animateButton
affected the view redraw.
Conclusion
Debugging SwiftUI views is an essential skill when working with dynamic properties on a view. Using the Self._printChanges
or Self._logChanges
static method allows us to find the root cause of a redraw. We can often solve frustrating animation issues by isolating views into single responsible views.
If you like to improve your SwiftUI knowledge even more, check out the SwiftUI category page. Feel free to contact me or tweet me on Twitter if you have any additional tips or feedback.
Thanks!