Search code examples
iosswiftswiftui

SwiftUI: Why SwiftUI rebuilds a view when an inrelevant property of @Observable data changed


I'm new to SwiftUI. I'm sorry if this is a stupid question.

@Observable
class Book {
    var title = "A Sample Book"
    var pageOne = Page()
}

class Page {
    var pageTitle = "pageTitle"
    var isAvailable = true
}

struct ContentView: View {
    @State private var book = Book()
    
    var body: some View {
        VStack{
            ChangeView(book: book)
            DisplayView(book: book)
        }
    }
}

struct ChangeView: View{
    @Bindable var book: Book
    
    var body: some View{
        Toggle(book.pageOne.isAvailable ? "page available" : "page not available",
               isOn: $book.pageOne.isAvailable
        )
    }
}

struct DisplayView: View {
    var book: Book

    var body: some View {
        Text(book.pageOne.pageTitle)
    }
}

The ChangeView is reading and watching book.pageOne.isAvailable. Every time I click the Toggle, the ChangeView's body will be rebuilt. That makes sense. But I found the DisplayView's body is also rebuilt every time I click the Toggle even if it doesn't care about the isAvailable at all. So my question is why SwiftUI rebuild DisplayView when a variable it doesn't care about changes? It seems to be different from what was said in the document. Is it because the isAvaialble is a subproperty of Observable data?


Solution

  • First, you should make Page also @Observable. Otherwise SwiftUI cannot track its changes properly.

    @Observable
    class Page {
        var pageTitle = "pageTitle"
        var isAvailable = true
    }
    

    Doing this alone is not enough to fix the problem, though. To fix this, you can either make a Bindable of the Page (in which case book need not be @Bindable),

    @Bindable var page = book.pageOne
    Toggle(book.pageOne.isAvailable ? "page available" : "page not available",
           isOn: $page.isAvailable
    )
    

    or do:

    Toggle(book.pageOne.isAvailable ? "page available" : "page not available",
           isOn: $book[dynamicMember: \.pageOne.isAvailable]
    )
    

    Explanation:

    When you do $book.pageOne.isAvailable, it is lowered (i.e. equivalent) to:

    $book[dynamicMember: \.pageOne][dynamicMember: \.isAvailable]
    

    The first pair of [] is a call to Bindable.subscript. This creates a Binding<Page>. The second pair of [] is a call to Binding.subscript, creating a Bindable<Bool>.

    If you have no idea what this is all about, see SE-0195 and SE-0252.

    The problem is this intermediate Binding<Page>. When the final Binding<Bool> gets changed by the toggle, the setter of isAvailable is obviously called, but from my observations, the setter of pageOne is also called.

    As Page is a reference type, this setter call is unnecessary. Perhaps this is because the Binding<Bool> is produced from the Binding<Page>. Bindings are originally designed to work with structs after all, and if Page were a struct, it is absolutely necessary to call the setter of pageOne.

    In any case, because the setter of pageOne is called, when isAvailable changes, SwiftUI thinks that pageOne also changes. Since DisplayView.body calls the getter of pageOne, SwiftUI would call DisplayView.body again when isAvailable changes.

    In both solutions, I eliminated the intermediate Binding<Page>, so the setter of pageOne does not get called.