Here is a simple example of a more complex problem I'm having.
In this case, I have an array of Object
s, 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.
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 Toggle
s 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