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)
}
}
}
}
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 View
s.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 View
s 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 ObservableObject
s 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()!)"
}
}