Skip to main content

Debugging SwiftUI Views Redraws

· 6 min read
Nikita Lazarev-Zubov

Debugging SwiftUI Views Redraws

When SwiftUI was introduced by Apple as a substitution to the old-fashioned UIKit in 2019, it immediately became a hit. Developers love that SwiftUI requires far less code to implement a user interface than the hundreds of lines of code necessitated in UIKit. However, as often happens, this new technology presented programmers with a steep learning curve. An initial lack of unambiguous documentation added fuel to the fire, leading developers to exchange their sometimes-frustrating experiences in communities like Stack Overflow.

Challenges Posed by SwiftUI

The reason for the initial confusion of SwiftUI adopters is that SwiftUI doesn’t follow common imperative principles of UIKit. In UIKit, we need to manually implement all view events and handlers of those events, and describe how views are rendered. But in SwiftUI, views are built on declarative principles: Using a concise DSL, we describe what views look like. The how part is left to the system.

SwiftUI views are value types; they don’t store any state and don’t need the developer to implement transitions between states. Instead, they have an identity. Since views are implemented with structs, changing their identity means that the old value is destroyed and the new value is created as a substitution. At this point, the SwiftUI engine redraws the view.

Taking both performance and user interface smoothness into consideration, Apple implemented SwiftUI in such a way that it doesn’t redraw everything at once. The engine isolates only those views with changed identities, and redraws them according to the rules we describe (e.g., animation.) Now, keeping in mind that views are composable, and that every element inside a view is a view itself, view redrawing and animation might not work exactly as we would expect.

Troubleshooting SwiftUI is often a challenge. To understand what happens under the hood, we need debugging. But how does debugging work in a value-oriented environment, where no state is ever preserved? Let’s see how SwiftUI views can be debugged using an example.

Problematic View Example

Let’s start with a simple view with just a couple of text labels, one of which is animated:

struct ContentView: View {


var body: some View {
VStack(spacing: 10.0) {
Text("Your score is")
.scaleEffect(isTitleScaling ? 1.2 : 1.0)
.rotationEffect(
Angle(degrees: isTitleRotating
? .random(in: -10.0...0.0)
: .random(in: 0.0...10.0))
)
Text("\(score)")
}
.onAppear {
withAnimation(
.easeInOut(duration: 0.5).delay(0.1).repeatForever()
) { isTitleScaling.toggle() }
withAnimation(
.easeInOut(duration: 1.0).delay(0.2).repeatForever()
) { isTitleRotating.toggle() }
}
}
private let score: Int
@State private var isTitleRotating = true
@State private var isTitleScaling = true

init(score: Int) {
self.score = score
}

}

This is what it looks like:

A simple view with animation

Figure 1: A simple view with animation

For now, it looks as expected. However, if we make it dynamic by updating score from an outside source, things immediately go south:

Broken animation in a dynamic view

Figure 2: Broken animation in a dynamic view

The animation becomes choppy and breaks when the score number is changed. Here’s the changed version of the view for comparison:

struct ContentView: View {
var body: some View {
VStack(spacing: 10.0) {
Text("Your score is")
.scaleEffect(isTitleScaling ? 1.2 : 1.0)
.rotationEffect(
Angle(degrees: isTitleRotating
? .random(in: -10.0...0.0)
: .random(in: 0.0...10.0))
)
Text("\(viewModel.score)")
}
.onAppear {
withAnimation(
.easeInOut(duration: 0.5).delay(0.1).repeatForever()
) { isTitleScaling.toggle() }
withAnimation(
.easeInOut(duration: 1.0).delay(0.2).repeatForever()
) { isTitleRotating.toggle() }
}
}
@State private var isTitleRotating = true
@State private var isTitleScaling = true
@StateObject private var viewModel = ContentViewModel()
}

The only changed thing is the source of the score text, which is now a @Published property of a view model:

final class ContentViewModel: ObservableObject {
@Published private(set) var score = 0
}

The initial source of the score value changes can be anything: a real time game update, a received response from a remote API, etc.

Let’s try to find the cause of the bug and fix it.

Debugging a View

One of the first things that come to mind when someone mentions debugging is breakpoints. We can go that route and simply put a breakpoint inside the body property. The next time that the ContentView’s identity is changed (i.e., any of its properties receives a new value, and the view is re-created), a redraw is invoked and the breakpoint is triggered. But what good is that?

Apple let slip information about a private static method _printChanges() that prints the reason for the last redraw. To use it, while on the breakpoint, type po Self._printChanges() (mind the Self part, since it’s a static method) in the LLDB console, and it will print out something like “ContentView: _score changed.” Voilà, now we know the reason that the view was redrawn!

Apparently, the timer—being a part of the entire view—triggers a redraw of the entire view, not just the text with the score count. To fix that, we can extract the animated text into a separate view, leaving the score count and its timer on their own:

struct ContentView: View {

var body: some View {
VStack(spacing: 10.0) {
ScoreTitle()
Text("\(viewModel.score)")
}
}
@StateObject private var viewModel = ContentViewModel()

private struct ScoreTitle: View {

var body: some View {
Text("Your score is")
.scaleEffect(isTitleScaling ? 1.2 : 1.0)
.rotationEffect(
Angle(degrees: isTitleRotating
? .random(in: -10.0...0.0)
: .random(in: 0.0...10.0))
)
.onAppear {
withAnimation(
.easeInOut(duration: 0.5).delay(0.1).repeatForever()
) { isTitleScaling.toggle() }
withAnimation(
.easeInOut(duration: 1.0).delay(0.2).repeatForever()
) { isTitleRotating.toggle() }
}
}
@State private var isTitleRotating = true
@State private var isTitleScaling = true

}

}

The fix works just fine:

Fixed animation together with a dynamic view

Figure 3: Fixed animation together with a dynamic view

Logging Redraw Triggers

Unfortunately, if we need to log changes of properties of a dynamic view, the debugger can’t help us. However, SwiftUI provides us with a view modifier onChange(of:perform:) that can be added to a view to track changes of any SwiftUI view property. Here’s an example that uses the Shipbook syntax:

.onChange(of: score) {
Log.d("New score is \($0)")
}

Conclusion

SwiftUI introduced a brand new mindset to building UI on Apple platforms, and learning it is not always easy. While implementing complex views, even an experienced developer might stumble and encounter confusing problems. To avoid unexpected behavior in as many cases as possible, always try to make your views small. This will enable you to reuse them more easily, and will help avoid bugs and failures.

When dealing with dynamic views, it’s even more vital to avoid introducing unwanted view redraws. Properties that trigger updates should affect only the views they change. When implementing animations, it’s always safer to make an animated view a separate SwiftUI View.

While troubleshooting unexpected behavior, the _printChanges method might prove useful. However, for logging purposes, SwiftUI’s view modifier onChanged is the best choice. It’s the perfect place to track changes of observable properties and log them remotely using Shipbook.