Search code examples
swiftswiftuiredraw

Prevent SwiftUI redrawing all subviews


Here is a simple example of a more complex problem I'm having.

In this case, I have an array of Objects, and a struct that holds the state for them.

In the ContentView, I'm displaying a custom view, MyToggle, for each object, and passing the state via a Binding

struct Object: Identifiable {
    let id = UUID()
    let name: String
}

struct ObjectStates {
    var states: [Object.ID: Bool] = [:]

    subscript(objectId: Object.ID) -> Bool {
        get { states[objectId, default: false] }
        set { states[objectId] = newValue }
    }
}

struct ContentView: View {

    let objects = [Object(name: "Toggle 1"), Object(name: "Toggle 2"), Object(name: "Toggle 3"), Object(name: "Toggle 4"), Object(name: "Toggle 5")]
    @State var objectStates = ObjectStates()
    
    var body: some View {
        List(objects) { object in
            MyToggle(name: object.name, isOn: $objectStates[object.id])
        }
    }
}

struct MyToggle: View {
    let name: String
    @Binding var isOn: Bool
    
    var body: some View {
        let _ = print(name)
        let _ = Self._printChanges()
        
        Toggle(name, isOn: $isOn)
    }
}

Each time a Toggle is changed, ObjectStates is updated, and all the subviews are redrawn. The Self._printChanges() is used to demonstrate this.

Is there a way to prevent all the subviews being redrawn when the state in the superview changes?

Interestingly, if I add another @State on the superview…

struct ContentView: View {

    let objects = [Object(name: "Toggle 1"), Object(name: "Toggle 2"), Object(name: "Toggle 3"), Object(name: "Toggle 4"), Object(name: "Toggle 5")]
    @State var objectStates = ObjectStates()
    @State var isOn = false. // added this State
    
    var body: some View {
        List {
            Toggle("Main", isOn: $isOn) // change it here
            ForEach(objects) { object in
                MyToggle(name: object.name, isOn: $objectStates[object.id])
            }
        }
    }
}

The subviews are not redrawn when this state is updated.


Solution

  • First to the reason why this happens.

    Both structs ObjectStates and Object are value types the same as the states dictionary. If you change a value type it gets destroyed and a new copy with the changed values is created. This new entity has a new id. The @State property wrapper detects changes by changes of the id of its wrapped value. That´s the reason we are using structs in SwiftUI. If you would use classes changes won´t reflect into the UI.

    When an @State property is changed it sends its objectWillChange publisher. The SwiftUI View now tries to evaluate if it needs to change the presented view. (Keep in mind the Views we are writing in SwiftUI are just a description of the view not the view itself.) To evaluate changes it calls the body var. Now when SubViews depend on any changed var it needs to evaluate these too. It can´t just guess if something changed or not. It has to run the body and compare it to the previous one.

    That´s what you are seeing here. The subviews depend on objectStates so it has to call all MyToggle body vars to determine if they changed or not. It won´t call them in your second example because they do not depend on the changed @State var isOn.

    Conclusion:

    I don´t think there is something wrong with your approach. Calling the body var multiple times shouldn´t be of any concern. They should be lightweighted and easy to destroy / create anyway. List should only call the body var of the visible elements, so there should be no impact on performance if you have larger collections.


    There is a work around for this. But it will have drawbacks as it uses a class to avoid the reevaluation of the subviews.

    Change the struct to a class and implement ObservableObject

    class ObjectStates: ObservableObject {
        var states: [Object.ID: Bool] = [:]
        
        subscript(objectId: Object.ID) -> Bool {
            get { states[objectId, default: false] }
            set { states[objectId] = newValue }
        }
    }
    

    Declare it @StateObject inside your View. This is needed to bind the lifecycle of the class to the view.

    @StateObject var objectStates = ObjectStates()
    

    Now you can use the Toggles without recreating the MyToggle struct.

    And of course the mandatory link to the video that´s more or less a must watch if working with SwiftUI -> Demystify SwiftUI