Ardent Swift

Stellar SwiftUI Performance Part 1 - View Struct is your friend

11 Jan 2025

I intentionally omit the part where I make excuses for defaulting on my ambition to post articles every 2 weeks, since I didn’t post anything for 4 months.

Oh wait…

What’s the point of this?

In a more complex SwiftUI Apps, as the Views get more heavy, the SwiftUI performance can quickly get out of hand. While the latest Apple devices are blazingly fast, we should still strive to not waste computation and power resources for unnecessary operations. And above all, I suppose pretty much everyone wants their App to run smoothly so that it feels just right. Let’s jump right into the next performance tip.

View Struct is your friend

The problem

After creating small View components I would write a bigger SwiftUI View that kinda is a UIViewController or NSViewController, and add name suffix - Screen. This Screen (heavily simplified) would usually look something like this:

@MainActor
final class LoginScreenModel: ObservableObject {

    @Published
    var username: String = ""

    @Published
    var password: String = ""
}

struct LoginScreen: View {

    @StateObject
    private var model: LoginScreenModel = .init()

    @State
    private var isAlertPresented: Bool = false // unused, just an example

    var body: some View {
        VStack {
            usernameField
            passwordField

            Spacer()

            loginButton
        }
        .padding()
        .toolbar { toolbarView }
    }
}

private extension LoginScreen {

    var toolbarView: some View {
        Button {

        } label: {
            Image(systemName: "xmark")
        }
    }

    var usernameField: some View {
        TextField("User name", text: $model.username)
            .padding()
            .background(.thinMaterial)
            .clipShape(RoundedRectangle(cornerRadius: 12))
    }

    var passwordField: some View {
        SecureField("Password", text: $model.password)
            .padding()
            .background(.thinMaterial)
            .clipShape(RoundedRectangle(cornerRadius: 12))
    }

    var loginButton: some View {
        Button("Login") {
            // Login logic
        }
        .buttonStyle(.borderedProminent)
    }
}

I always thought that once LoginScreenModel’s username property changes, it would trigger SwiftUI View diffing but only for the usernameField computed property, since it is the only place where model.username is used. But that’s not true, as I learned thanks to Instruments.

Whenever any StateObject’s or State property value changes, the whole var body: some View has to perform diffing and check for changes, but so do all computed properties and the computed properties’ subviews until it reaches another View struct that doesn’t depend on our LoginScreen’s State properties.

Keep in mind: Even though properties unrelated to the subview in question don’t trigger the actual redraw (on a CoreAnimation/CoreGraphics level), the SwiftUI diffing itself is pretty expensive, especially since it always runs on the MainActor.

We can verify this by adding this handy Self._printChanges() magic spice like so:

var usernameField: some View {
    let _ = Self._printChanges()
    
    ...
}

Now if you start typing into the password’s SecureField, thus changing the model.password, property on the LoginScreenModel, the console would output something like this:

LoginScreen: _model changed.

But of course in the case of an unrelated property, we don’t really need SwiftUI to even recompute the property to perform the diffing.

The Solution

What even Apple recommends is to break the Views not into computed properties, but separate structs. If we do decide to keep our usernameField computed property for var body: some View’s readability’s sake, we should create a separate struct like so:

fileprivate struct UsernameFieldView: View {

    @Binding
    var text: String

    var body: some View {
        let _ = Self._printChanges()

        TextField("User name", text: $text)
            .padding()
            .background(.thinMaterial)
            .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

and then refactor our computed property on the LoginScreen:

var usernameField: some View {
    UsernameFieldView(text: $model.username)
}

Now when you type into the password’s SecureField, the let _ = Self._printChanges() inside of the UsernameFieldView will not print anything. In our case this means that SwiftUI is not even recomputing the whole contents of UsernameFieldView since it knows that it only depends on the model.username and that hasn’t changed.

Conclusion

It’s embarassing, but after 3 years of working with SwiftUI this is something I learned just recently. As a good practice it’s probably to write it in a way that doesn’t do the unnecessary diffing straight away, but if your App’s performance is good enough and the Views are rather not complex, then there is no need to fall into the premature optimization trap 🪤. It’s all about striking the right balance.

Happy coding and stay tuned for more SwiftUI tips, (hopefully) coming up next week! 💻