Give your simulator superpowers

Give your Xcode
Simulator extra features

Debugging SwiftUI views: what caused that change?

Debugging SwiftUI views is an essential skill to own when writing dynamic views with several redrawing triggers. Property wrappers like @State and @ObservedObject will redraw your view based on a changed value. In many cases, this is 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.

Alright, 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 I think 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 understand what I mean by dynamic fully. 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 that this view will not change. Therefore, we can call this a static view. Debugging SwiftUI views that are static is likely not often needed.

The problem of a Massive SwiftUI View

A SwiftUI view with many redraw triggers can be a pain to work with. Each individual @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, which will make 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 find out 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 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 disable the timer and rerun our app. You’ll see a smooth animation not causing any problems anymore.

This was just a simple debugging example. Using Self._printChanges() can be helpful in cases you want to find out which state property triggered a redraw.

Solving redraw issues in SwiftUI

Before diving into other debugging techniques, I think 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 in any other place 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._printChanges() method, we can look into other valuable ways of using this method. Setting a breakpoint like in the previous example only works when you know which view is causing problems. There could be cases that you have multiple affected views since they all monitor the same observed object.

Using code could be a solution since it does not require entering lldb commands after a breakpoint hits manually. The code change speeds up debug processes since it will constantly run while your views redraw. We can use this technique as follows:

var body: some View {
    Self._printChanges()
    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).

Conclusion

Debugging SwiftUI views is an essential skill to own when working with dynamic properties on a view. By using the _printChanges static method, we allow ourselves 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!

 

Featured SwiftLee Jobs

Loading RSS Feed

Browse more Swift related Jobs, or add your own on SwiftLee Jobs