0

I would like help to further understand the implications of using the following 2 methods for driving data between multiple views.

My situation: A parent view initialises multiple child views with data passed in.

  • This data is a big object.
  • Each view takes a different slice of the data.
  • Each view can manipulate the initial data (filtering, ordering etc)

Using an observableObeject to store this data and multiple published properties for each view :

  • can be passed in as an environment object that can be accessed by any view using @EnvironmentObject.
  • You can create a Binding to the published properties and change them.
  • Execute a method on the ObservableObject class and manipulate a property value which gets published using objectWillChange.send() inside the method.

I have achieved the desired listed above by using a struct with mutating methods. Once these properties are changed in the struct, the views which bind to these properties causes a re-render.

My struct does not do any async work. It sets initial values. Its properties are modified upon user action like clicking filter buttons.

Example

struct MyStruct {
    var prop1 = "hello"
    var prop2: [String] = []
    
    init(prop2: [String]) {
        self.prop2 = prop2
    }
    
    
    mutating func changeProp2(multiplier: Int) {
        let computation = ...
        prop2 = computation //<----- This mutates prop2 and so my view Binded to this value gets re-renderd.
    }
}

struct ParentView: View {
    var initValue: [String] // <- passed in from ContentView
    @State private var myStruct: MyStruct
    
    init(initValue: [String]) {
        self.myStruct = MyStruct(prop2: initValue)
    }
    
    var body: some View {
        VStack {
            
            SiblingOne(myStruct: $myStruct)
            SiblingTwo(myStruct: $myStruct)
        }
    }
}


struct SiblingOne: View {
    @Binding var myStruct: MyStruct
    var body: some View {
        HStack{
            Button {
                myStruct.changeProp2(multiplier: 10)
            } label: {
                Text("Mutate Prop 2")
            }

        }
    }
}


struct SiblingTwo: View {
    @Binding var myStruct: MyStruct
    var body: some View {
        ForEach(Array(myStruct.prop2.enumerated()), id: \.offset) { idx, val in
            Text(val)
        }
    }
}

Question:

What use cases are there for using an ObservableObject than using a struct that mutates its own properties?

There are overlap use cases however I wish to understand the differences where:

  1. Some situation A favours ObservableObject
  2. Some situation B favours struct mutating properties
TylerH
  • 20,799
  • 66
  • 75
  • 101
meteorBuzz
  • 3,110
  • 5
  • 33
  • 60
  • struct is a value type, example `var x = 10; var y = x; y = 20;` Here `x` will still stay `10`. During assignment of a value type, value is copied, in `y` the new copy is which is being modified. So in your case child view modifying a struct value, the change will not be reflected in other views. How are you able to mutate a struct variable in a view, it shouldn't be possible. Show a minimum reproducible example so that it is clearer to explain and understand – user1046037 Nov 07 '22 at 08:24
  • Please watch https://developer.apple.com/wwdc20/10040 it explains clearly – user1046037 Nov 07 '22 at 08:27
  • Added further code to help illustrate my question – meteorBuzz Nov 07 '22 at 09:41
  • Binding passes a references to the value, that is why it works. So changing the binding value will reflect in the other views. There are different tools in the toolbox, there is `@State`, `@StateObject`, `@ObservedObject`, `@EnvironmentObject`, `@Binding`, use the right tools for the right scenario. Please watch the video posted earlier, it will clear most of your doubts. – user1046037 Nov 07 '22 at 11:49

1 Answers1

1

Before I begin, when you say "these properties causes a re-render" nothing is actually re-rendered all that happens is all the body that depend on lets and @State vars that have changed are invoked and SwiftUI builds a tree of these values. This is super fast because its just creating values on the memory stack. It diffs this value tree with the previous and the differences are used to create/update/remove UIView objects on screen. The actual rendering is another level below that. So we refer to this as invalidation rather than render. It's good practice to "tighten" the invalidation for better performance, i.e. only declare lets/vars in that View that are actually used in the body to make it shorter. That being said no one has ever compared the performance between one large body and many small ones so the real gains are an unknown at the moment. Since these trees of values are created and thrown away it is important to only init value types and not any objects, e.g. don't init any NSNumberFormatter or NSPredicate objects as a View struct's let because they are instantly lost which is essentially a memory leak! Objects need to be in property wrappers so they are only init once.

In both of your example situations its best to prefer value types, i.e. structs to hold the data. If there is just simple mutating logic then use @State var struct with mutating funcs and pass it into subviews as a let if you need read access or @Binding var struct if you need write access.

If you need to persist or sync the data then that is when you would benefit from a reference type, i.e. an ObservableObject. Since objects are created on the memory heap these are more expensive to create so we should limit their use. If you would like the object's life cycle to be tied to something on screen then use @StateObject. We typically used one of these to download data but that is no longer needed now that we have .task which has the added benefit it will cancel the download automatically when the view dissapears, which no one remembered to do with @StateObject. However, if it is the model data that will never be deinit, e.g. the model structs will be loaded from disk and saved (asynchronously), then it's best to use a singleton object, and pass it in to the View hierarchy as an environment object, e.g. .environmentObject(Store.shared), then for previews you can use a model that is init with sample data rather that loaded from disk, e.g. .environmentObject(Store.preview). The advantage here is that the object can be passed into Views deep in the hierarchy without passing them all down as let object (not @ObservedObject because we wouldn't want body invovked on these intermediary Views that don't use the object).

The other important thing is your item struct should usually conform to Identifiable so you can use it in a ForEach View. I noticed in your code you used ForEach like a for loop on array indices, that's a mistake and will cause a crash. It's a View that you need to supply with Indentifiable data so it can track changes, i.e. moves, insertions, deletions. That is simply not possible with array indices, because if the item moves from 0 to 1 it still appears as 0.

Here are some examples of all that:

struct UserItem: Identifiable {
    var username: String

    var id: String {
        username
    }
}
class Store: ObservableObject {

    static var shared = Store()
    static var preview = Store(preview: true)
 
    @Published var users: [UserItem] = []

    init(preview: Bool = false) {
        if (preview) {
            users = loadSampleUsers()
        }
        else {
            users = loadUsersFromDisk()
        }
    }
@main
struct TestApp: App {

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(Store.shared)
        }
    }
}

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                ForEach($store.users) { $user in
                    UserView(user: $user)
                }    
            }          
         } 
     }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environmentObject(Store.preview)
    }
}
struct UserView: View {
    @Binding var user: UserItem
    var body: some View {
        TextField("Username", text: $user.username)
    }
}
malhal
  • 26,330
  • 7
  • 115
  • 133