M1X
M1X

Reputation: 5354

SwiftUI trigger function when model updates inside @Published property

I am getting an array from a Firestore table. Obviously is in real time so any newly added document or modifications to an existing document are reflected in the view. This works for simple properties like strings and numbers but I need to display and refresh other data too.

I'm storing the coordinates also and if the coordinates change I need to call geocoder.reverseGeocodeLocation() in order to get the Placemark out of this location and display the address as a string. For this I have created a service called PlacemarkService (code provided below).

The issue is this. Whenever a model is modified the data comes through PackagesViewModel which has a @Published var results = [Package]() This is modified on the fly by Firestore (codable). Then, any changes caused to array this will cause the List inside PackagesView to be refreshed and this will cause a new instance of PackageView(packageService: PackageService(package: package)) to be created and since the PackageService is passed as a param here a new instance of PackageService will be created. Finally this will trigger the init() inside this service to be called.

@Published var results = [Package]() -> List -> PackageView(packageService: PackageService(package: package)) -> init()

So now if I follow this flow and I trigger getPlacemarks() inside init everything works (most of the time) and if the package model changes this function will be called on init and will reverseGeocodeLocation of the modified location. But there is a big problem here and here is why. I can not put this logic on init since I can not geocode hundreds of locations inside a for loop. I have multiple packages displayed on a list and MapKit won't allow this.

So obviously this logic needs to be triggered after the PackageView is shown, so when one of those packages is selected.

If I call packageService.getPlacemarks() when PackageView appears this works fine the first time but... onAppear won't be triggered when @Published var results = [Package]() is updated.

So finally the question is:

Where to call packageService.getPlacemarks() so it gets called whenever a Package inside @Published var results = [Package] is updated

but not on init of PlacemarkService for the reason I explained above.

Sorry for the long explanation, I am just trying to be clear.

class PackageService: ObservableObject {
    var package: Package
    private var cancellables = Set<AnyCancellable>()
    
    @Published var sourcePlacemarkService = PlacemarkService()
    @Published var destinationPlacemarkService = PlacemarkService()
    
    var cancellable: AnyCancellable? = nil
    
    init(package: Package) {
        self.package = package
        startListening()
    }
    
    func startListening() {
        // Listen to placemark changes.
        cancellable = Publishers.CombineLatest(sourcePlacemarkService.$placemark, destinationPlacemarkService.$placemark).sink(receiveValue: {_ in
            
            // Publish changes manually to the view.
            self.objectWillChange.send()
        })
    }
    
    // Get placemarks from locations
    func getPlacemarks() {
        sourcePlacemarkService.reverseGeocodeLocation(location: package.source.toCLLocation)
        destinationPlacemarkService.reverseGeocodeLocation(location: package.destination.toCLLocation)
    }
}

class PackagesViewModel: ObservableObject {
    @Published var results = [Package]()
    
    func load() {
        query.addSnapshotListener { (querySnapshot, error) in 
            // updates results array when a document is modified.
        }
    }
}


struct PackagesView: View {
    @StateObject var packagessViewModel = PackagesViewModel()

    var body: some View {
        List(packagessViewModel.results, id: \.self) { package in
            NavigationLink(destination: PackageView(packageService: PackageService(package: package))) {
                Text(package.title)
            }
        }
    }
}

struct PackageView: View {
    @ObservedObject var packageService: PackageService

    func onAppear() {
        packageService.getPlacemarks()
    }
    
    var body: some View {
        // show the address from placemark after it is geocoded.
        VStack {
            Text(packageService.sourcePlacemarkService.placemark.title)
            Text(packageService.destinationPlacemarkService.placemark.title)
        }
        .onAppear(perform: onAppear)
    }
}

class PlacemarkService: ObservableObject {
    @Published var placemark: CLPlacemark?
    
    init(placemark: CLPlacemark? = nil) {
        self.placemark = placemark
    }
    
    func reverseGeocodeLocation(location: CLLocation?) {
        if let location = location {
            geocoder.reverseGeocodeLocation(location, completionHandler: { (placemark, error) in
                // some code here
                self.placemark = placemark
            })
        }
    }
}

struct Package: Identifiable, Codable {
    @DocumentID var id: String?
    var documentReference: DocumentReference
    
    var uid: String
    var title: String
    var description: String
    var source, destination: GeoPoint
    var amount: Double
    
    var createdAt: Timestamp = Timestamp()
    var paid: Bool = false
}

Upvotes: 2

Views: 7871

Answers (2)

George
George

Reputation: 30361

Firstly - simplifying this code. Most of this code is unnecessary to reproduce the problem, and can't even be compiled. The code below is not the working code, but rather what we have to start and change later:

ContentView

struct ContentView: View {
    var body: some View {
        NavigationView {
            PackagesView()
        }
    }
}

PackageService

class PackageService: ObservableObject {
    let package: Package

    init(package: Package) {
        self.package = package
    }

    // Get placemarks from locations
    func getPlacemarks() {
        print("getPlacements called")
    }
}

PackagesViewModel

class PackagesViewModel: ObservableObject {
    @Published var results = [Package]()
}

PackagesView

struct PackagesView: View {
    @StateObject var packagesViewModel = PackagesViewModel()

    var body: some View {
        VStack {
            Button("Add new package") {
                let number = packagesViewModel.results.count + 1
                let new = Package(title: "title \(number)", description: "description \(number)")
                packagesViewModel.results.append(new)
            }

            Button("Change random title") {
                guard let randomIndex = packagesViewModel.results.indices.randomElement() else {
                    return
                }

                packagesViewModel.results[randomIndex].title = "new title (\(Int.random(in: 1 ... 100)))"
            }

            List(packagesViewModel.results, id: \.self) { package in
                NavigationLink(destination: PackageView(packageService: PackageService(package: package))) {
                    Text(package.title)
                }
            }
        }
    }
}

PackageView

struct PackageView: View {
    @ObservedObject var packageService: PackageService

    var body: some View {
        VStack {
            Text("Title: \(packageService.package.title)")

            Text("Description: \(packageService.package.description)")
        }
    }
}

Package

struct Package: Identifiable, Hashable {
    let id = UUID()
    var title: String
    let description: String
}

Now, solving the problem. I fixed this issue by detecting the results changing with onChange(of:perform:). However, from here there is no way to access the PackageServices used in the view body.

To prevent this issue, the PackageServices are actually stored in PackagesViewModel, which logically makes more sense for the data flow. Now with PackageService also being a struct so the @Published works on the array for results, this now works.

See the code below:

PackageService (updated)

struct PackageService: Hashable {
    var package: Package

    init(package: Package) {
        self.package = package
    }

    // Get placemarks from locations
    mutating func getPlacemarks() {
        print("getPlacements called")

        // This function is mutating, feel free to set any properties in here
    }
}

PackagesViewModel (updated)

class PackagesViewModel: ObservableObject {
    @Published var results = [PackageService]()
}

PackagesView (updated)

struct PackagesView: View {
    @StateObject var packagesViewModel = PackagesViewModel()

    var body: some View {
        VStack {
            Button("Add new package") {
                let number = packagesViewModel.results.count + 1
                let new = Package(title: "title \(number)", description: "description \(number)")
                packagesViewModel.results.append(PackageService(package: new))
            }

            Button("Change random title") {
                guard let randomIndex = packagesViewModel.results.indices.randomElement() else {
                    return
                }

                let newTitle = "new title (\(Int.random(in: 1 ... 100)))"
                packagesViewModel.results[randomIndex].package.title = newTitle
            }

            List($packagesViewModel.results, id: \.self) { $packageService in
                NavigationLink(destination: PackageView(packageService: $packageService)) {
                    Text(packageService.package.title)
                }
            }
        }
        .onChange(of: packagesViewModel.results) { _ in
            for packageService in $packagesViewModel.results {
                packageService.wrappedValue.getPlacemarks()
            }
        }
    }
}

PackageView (updated)

struct PackageView: View {
    @Binding var packageService: PackageService

    var body: some View {
        VStack {
            Text("Title: \(packageService.package.title)")

            Text("Description: \(packageService.package.description)")
        }
    }
}

Upvotes: 4

Phil Dukhov
Phil Dukhov

Reputation: 87804

I think that easiest way to solve your problem is initially call getPlacemarks in onAppear, and re-calling it with onChange, like this:

VStack {
    Text(packageService.sourcePlacemarkService.placemark.title)
    Text(packageService.destinationPlacemarkService.placemark.title)
}
.onChange(of: packageService.package.id) { _ in
    packageService.getPlacemarks()
}
.onAppear {
    packageService.getPlacemarks()
}

Depending on which fields of your package struct change, you may need make it Equtable and pass the whole object instead of id.

Upvotes: 2

Related Questions