SwiftUI rendering pitfalls

Tue, 28 Mar 2023 00:00:00 +0000

Know why your view is not rendering why you think it should have.

Mastering SwiftUI rendering cycle is not easy and it's common to face issues with it such as having your views rendering too often or not rendering at all while some state changed.

In this article we'll focus on the second issue and see some edge-cases where your view is not rendering while you think it should have.

We'll look to it through the Smart/Dumb components perspective that was explained in Smart and Dumb views in SwiftUI. But note that these rendering issues are related to SwiftUI itself and not the pattern. So even if not using it you might still face them.

Dumb component

As a reminder SwiftUI evaluates a view body when any of its properties change. It checks if any of the parameter you pass to init changed. This is especially true for closures.

Let's take this Dumb component as an example:

struct BookList : View {
    let books: [Book]
    let isBought: (Book) -> Bool
    let buy: (Book) -> Void

    var body: some View {
        List {
            ForEach(books) { book in
                let isBookBought = isBough(book)

                Button(action: { buy(book) }) {
                    Text(isBookBought ? "Purchased" : "Buy")
                }
                .disabled(isBookBaught)
            }
        }

    }
}

It takes as input a list of Book and two closures. Let's use it from another view in two (slightly different) ways:

// 1st
struct LibraryScreen: View {
    @StateObject var viewModel: LibraryViewModel

    var body: some View {
        BookList(books: viewModel.books, isBought: { viewModel.isBought($0) }, buy: viewModel.buy)
    }
}

// 2nd
struct LibraryScreen: View {
    @StateObject var viewModel: LibraryViewModel

    var body: some View {
        BookList(books: viewModel.books, isBought: viewModel.isBought, buy: viewModel.buy)
    }
}

The two codes are similar, the only difference being how isBought is defined:

🥷 through an anonymous closure

🏷️ using a method reference

Only one of these two samples will reflect the new state when user buys a book. Can you guess which one and why? The first one.

Indeed every time SwiftUI evaluates LibraryScreen.body a new anonymous closure is created. Therefore BookList input changed and BookList.body is evaluated again.

In the second example viewModel.isBought is referring to a method. This reference never changes. Because BookList input had no modification BookList.body is not rendered again.

SwiftUI being a closed source framework it is hard to say precisely how it works leading to some mysteries and assumptions 🔮. Since iOS15 you can use Self._printChanges in your body which might help in debugging and understanding when SwiftUI decides to evaluates your body. You can also use profiling tools.

I'm talking about anonymous closures but note this seems to be true for any non Equatable created on the fly 🦋, like Binding(get: { ... }, set: { ... }) for instance.

What about buy then? Even if we use a reference method the view updates 🤯.

This is because buy is an action: we return nothing. We don't rely on any value computed by this function. Therefore using a closure or a reference method has no impact here.

In summary:

Use Self._printChanges to debug why a view get re-rendered

Be careful when using a reference method returning a value

Smart component

If you read Smart and Dumb views in SwiftUI you know you should not use Smart components other than as root views.

In some very rare cases you might need one though. Unfortunately by doing so you also probably faced the issue of having not the view being updated while the state changed... Did it?

How to know if you need Smart component? If you need to do store some asynchronous work you may need a Smart component. Otherwise you're probably fooling yourself by thinking you need one and can actually just lift the state up!

Let's take an example:

struct ContactView: View {
    let contact: Contact

    var body: some View {
        CallButton(viewModel: CallButtonViewModel(number: contact.number))
    }
}

Here we have a Dumb component (ContactView) using a Smart view (CallButton) which is initialized with the contact number. This is working fine until someone decides to change the contact phone number.

In this case when taping on the button you'll call the contact... using its old phone number 😳.

This actually makes sense. @State(Object) are restored with their initial value on every rendering cycle. So our view ViewModel is always restored to its very first instance. This is how we can have immutable structs (ContactView) and yet being stateful.

To avoid the state to be restored we need to reset it by stating to SwiftUI that our CallButton identity changed. In order to do so we can rely on View.id(_:):

struct ContactView: View {
    let contact: Contact

    var body: some View {
        CallButton(viewModel: CallButtonViewModel(number: contact.number))
            .id(contact.number)
    }
}

Now whenever our contact number changes the whole button state will be reseted.