Search code examples
swiftswiftuiswiftui-list

Individual objects in object array in swiftui not getting updated


I am working on an app and I am retrieving persons data from a server as shown in the PersonVM (person view model) as shown in the code snippet below. After getting the data from the server, I am making an additional request to retrieve extra data from a server to update each person in the persons array. However, the server request being made to retrieve extra data for each person only works for some of the persons in the persons array. Is there something I am doing wrong?

struct Person: Identifiable {
   let id = UUID()
   var firstname: String
   var lastname: String
   var extraData: String
   var url: String
}

class PersonVM: ObservableObject {
  @Published var persons = [Person]()

  @MainActor
  public func getExtraData() async {
    // I get the persons data from a server
    // and I looping over each person making a 
    // network request to get extra data using person.url
    persons = data from server
    DispatchQueue.main.async {
      persons.indices.forEach { index in 
        Task {
           let extraData = try await self.getExtraData(url:person[index].url)
           self.persons[index].extraData = extraData
        }
      }
    }

  }

struct PersonView: View {
    @Observable va personVM = PersonVM()

    var body: some View {
      List {
         ForEach(personVM.persons){
             person in
             PersonRowView(p: person)
         }
      }
    }
}

Solution

  • Reference Types

    iOS 13-16 - ObservableObject

    You need a chain of tools to redraw a View with reference types.

    A class that conforms to ObservableObject and has variables that are wrapped with @Published.

    Then the instance of the ObservableObject must be wrapped in a View or DynamicProperty with one of the appropriate wrappers the wrapping must be done at every layer where you want to see changes.

    The ObservableObject wrappers are

    • @StateObject for initializing
    • @ObservedObject for passing around
    • @EnvironmentObject paired with the ViewModifier .environmentObject() to make the object available to child Views.

    iOS 17

    Introduced @Observable this is a macro.

    You add this to the class (not a variable) something like @Observable class Person {}

    This macro has the ability to redraw SwiftUI Views on its own. There is no need for any other wrappers to see changes.

    Now to edit an @Observable object you need @Bindable in the View.

    Here is a little curveball, pre-iOS 17 @State was typically reserved for value-types because it is not capable of seeing changes for Reference-types including ObservableObject.

    It is now capable of being changes for @Observable types.

    Instantiate an observable model data type directly in a view using a State property. Share the observable model data with other views in the hierarchy without passing a reference using the Environment property wrapper.

    Here are some references

    https://developer.apple.com/documentation/swiftui/model-data

    https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

    If I assume @Observable is a typo because that line would not be allowed. Here is a working sample of what you code looks like.

    import SwiftUI
    
    struct PersonView: View {
        @StateObject private var personVM = PersonVM() //Observes changes to the `ObservableObject`
        //Keeps track of the task that is getting the extra data
        @State private var isGettingExtra: Bool = false
        var body: some View {
            List {
                Button {
                    isGettingExtra.toggle() //Use a `Button`, `.task` or any other interaction to trigger  `.task(id: isGettingExtra)`
                } label: {
                    Text("Get extra data")
                        .overlay {
                            if isGettingExtra { //Show the user something while the task is running
                                ProgressView()
                            }
                        }
                }.task(id: isGettingExtra) {
                    guard isGettingExtra else {
                        return
                    }
                    do {
                        try await personVM.getExtraData()
                    } catch {
                        print(error)
                    }
                    isGettingExtra = false
                }
                
                ForEach($personVM.persons){
                    $person in
                    PersonRowView(p: $person)
                }
            }
        }
    }
    struct PersonRowView: View {
        @Binding var p: Person //Binding is a two-way connection with value types.
        var body: some View {
            HStack {
                TextField("first name", text: $p.firstname)
                Text("Extra Data \(p.extraData)")
            }
        }
    }
    
    class PersonVM: ObservableObject {
        @Published var persons: [Person] = (0...10).map { n in
            Person(firstname: n.description, lastname: n.description, extraData: n.description, url: n.description)
        }
        
        
        public func getExtraData() async throws {
            // I get the persons data from a server
            // and I looping over each person making a
            // network request to get extra data using person.url
    
            persons = data from server
    
            //Don't mix `Task`, `async` or `await` with `DispatchQueue` the new Concurrency replaces older methodologies.
            for (index, _) in persons.enumerated(){
                let extraData = try await self.getExtraData(url:persons[index].url)
                self.persons[index].extraData = extraData
            }
        }
        func getExtraData(url: String) async throws -> String {
            try await Task.sleep(for: .seconds(1))
            return "\((0...100).randomElement()!)"
        }
    }
    

    If you are supporting iOS 17+ you should forget about ObservableObjects and just use @Observable correctly.

    struct PersonView: View {
         @State private var personVM = PersonVM() //Use `@State` instead of `@StateObject`
         @State private var isGettingExtra: Bool = false
         var body: some View {
             List {
                 Button {
                     isGettingExtra.toggle()
                 } label: {
                     Text("Get extra data")
                         .overlay {
                             if isGettingExtra {
                                 ProgressView()
                             }
                         }
                 }.task(id: isGettingExtra) {
                     guard isGettingExtra else {
                         return
                     }
                     do {
                         try await personVM.getExtraData()
                     } catch {
                         print(error)
                     }
                     isGettingExtra = false
                 }
                 
                 ForEach($personVM.persons){
                     $person in
                     PersonRowView(p: $person)
                 }
             }
         }
     }
    
     @Observable
     class PersonVM {
         var persons: [Person] = (0...10).map { n in
             Person(firstname: n.description, lastname: n.description, extraData: n.description, url: n.description)
         }
         
         
         public func getExtraData() async throws {
             // I get the persons data from a server
             // and I looping over each person making a
             // network request to get extra data using person.url
             //persons = data from server
             for (index, _) in persons.enumerated(){
                 let extraData = try await self.getExtraData(url:persons[index].url)
                 self.persons[index].extraData = extraData
             }
         }
         func getExtraData(url: String) async throws -> String {
             try await Task.sleep(for: .seconds(1))
             return "\((0...100).randomElement()!)"
         }
     }