Search code examples
swiftswiftuiappstorage

AppStorage variables are not updated the first time


As said in the title, 2 of the 3 @AppStorage variables aren't updated the first time. So, when the view is dismissed, I have to re-open it and then at the second dismiss they're all updated. To be precise, only storedCityName is updated the first time.

Here's the view:

import SwiftUI

struct SearchView: View {
    @Environment(\.dismiss) private var dismiss
    @EnvironmentObject private var searchManager: SearchManager
    @AppStorage("storedUserLatitude") private var storedUserLatitude: Double?
    @AppStorage("storedUserLongitude") private var storedUserLongitude: Double?
    @AppStorage("storedCityName") private var storedCityName: String?

    var body: some View {
        VStack {
            TextField("", text: $searchManager.search)

            ForEach(searchManager.searchResults, id: \.self) { result in
                HStack {
                    VStack {
                        Text(result.title)
                        Text(result.subtitle)
                    }
                }
                .onTapGesture { update(result.title) }
            }
        }
    }

    private func update(_ city: String) {
        searchManager.searchLocation(city)
        storedUserLatitude = searchManager.userLocation?.coordinate.latitude
        storedUserLongitude = searchManager.userLocation?.coordinate.longitude
        storedCityName = city
        dismiss()
    }
}

And, here's the manager:

import Foundation
import MapKit
import Combine

class SearchManager: NSObject, ObservableObject, MKLocalSearchCompleterDelegate {
    private let completer = MKLocalSearchCompleter()
    @Published var search = ""
    @Published var searchResults = [MKLocalSearchCompletion]()
    @Published var userLocation: CLLocation?
    private var publisher: AnyCancellable?

    override init() {
        super.init()
        completer.delegate = self
        completer.resultTypes = .address

        publisher = $search
            .receive(on: RunLoop.main)
            .sink(receiveValue: { string in
                self.completer.queryFragment = string
            })
    }

    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        searchResults = completer.results
    }

    func searchLocation(_ city: String) {
        let geocoder = CLGeocoder()

        geocoder.geocodeAddressString(city) { placemarks, error in
            guard let placemark = placemarks?.first else { return }
            self.userLocation = placemark.location
        }
    }
}

I really don't understand why it works at the second try but not at the first one.

I also tried to delay the dismiss with DispatchQueue.main.asyncAfter(deadline: .now() + 2) { dismiss() } to see if the @AppStorage variables needed time to update but it doesn't change anything.

Do you have any idea of what's going on?

Thanks, in advance!


Solution

  • The real problem here is that you call your reverse geocoder with searchManager.searchLocation(city), which uses an async function to do the reverse geocoding. While you pass a block to that to set the values in your search manager, the code in SearchView isn't waiting for that information.

    On the second try, your SearchManager environment object has had time to update with the geocoding from the first attempt.

    You should consider updating searchLocation to make sure that it only runs the subsequent code in update once the geocoding has happened. One way would be to allow it to receive a block:

    private func update(_ city: String) {
      searchManager.searchLocation(city) {
        storedUserLatitude = searchManager.userLocation?.coordinate.latitude
        storedUserLongitude = searchManager.userLocation?.coordinate.longitude
        storedCityName = city
        dismiss()
      }
    }
    
    // in SearchManager
    func searchLocation(_ city: String, onComplete: () -> Void) {
      let geocoder = CLGeocoder()
    
      geocoder.geocodeAddressString(city) { placemarks, error in
        guard let placemark = placemarks?.first else { return }
        self.userLocation = placemark.location
        onComplete()
      }
    }
    

    There are neater ways, and you'd need to handle the error path properly, but hopefully this gives you the right idea.