Reputation: 5354
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
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:
struct ContentView: View {
var body: some View {
NavigationView {
PackagesView()
}
}
}
class PackageService: ObservableObject {
let package: Package
init(package: Package) {
self.package = package
}
// Get placemarks from locations
func getPlacemarks() {
print("getPlacements called")
}
}
class PackagesViewModel: ObservableObject {
@Published var results = [Package]()
}
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)
}
}
}
}
}
struct PackageView: View {
@ObservedObject var packageService: PackageService
var body: some View {
VStack {
Text("Title: \(packageService.package.title)")
Text("Description: \(packageService.package.description)")
}
}
}
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 PackageService
s used in the view body.
To prevent this issue, the PackageService
s 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:
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
}
}
class PackagesViewModel: ObservableObject {
@Published var results = [PackageService]()
}
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()
}
}
}
}
struct PackageView: View {
@Binding var packageService: PackageService
var body: some View {
VStack {
Text("Title: \(packageService.package.title)")
Text("Description: \(packageService.package.description)")
}
}
}
Upvotes: 4
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