Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

Win a ticket for the Do iOS Conference in Amsterdam Join here.

Debugging SwiftUI views: what caused that change?

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:

Our button animation jumps since multiple triggers redraw the same view.

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.

Stay updated with the latest in Swift & SwiftUI

Join 19,972 Swift developers in our exclusive newsletter for the latest insights, tips, and updates. Don't miss out – join today!

You can always unsubscribe, no hard feelings.

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:

Debugging SwiftUI views using Self._printChanges() to find a redraw trigger.
Debugging SwiftUI views using Self._printChanges() to find a redraw trigger.

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:

Our button is animating smoothly while the timer 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 of body 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!

 
Antoine van der Lee

Written by

Antoine van der Lee

iOS Developer since 2010, former Staff iOS Engineer at WeTransfer and currently full-time Indie Developer & Founder at SwiftLee. Writing a new blog post every week related to Swift, iOS and Xcode. Regular speaker and workshop host.