Search code examples
swiftcombine

Stop subscriber (sink) from running multiple times


In my CoordinatesViewModel file i have function addWaypoint that gets called when user presses the button. In LocationManager file i have function that gets users location and then stops receiving location updates. To access location from LocationManager file i use .sink (i am new to Swift so i don’t know if .sink is the right way about doing this). Now the problem i noticed is that .sink sometimes runs twice or more so the same result gets added to array. This usually happens the second time i press the button.

Here is the example what i get from print into console when i run the app:

hello from ViewModel
hello from LocationManager
hello from ViewModel

LocationManager:

    private let manager = CLLocationManager()
    @Published var userWaypoint: CLLocation? = nil
 
    override init(){
        super.init()
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.requestWhenInUseAuthorization()
        manager.delegate = self
    }

    func getCurrentUserWaypoint(){
        wasWaypointButtonPressed = true
        manager.requestWhenInUseAuthorization()
        manager.startUpdatingLocation()
    }

extension LocationManager: CLLocationManagerDelegate{
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let userWaypoint = locations.last  else {return}
        print("hello from LocationManager")
        DispatchQueue.main.async {
            self.userWaypoint = userWaypoint
        }
        self.manager.stopUpdatingLocation()
    }
}

CoordinatesViewModel:

private let locationManager: LocationManager
var cancellable: AnyCancellable?
@Published var waypoints: [CoordinateData] = []


init() {
    locationManager = LocationManager()
}

func addWaypoint(){
    locationManager.getCurrentUserWaypoint()
    cancellable = locationManager.$userWaypoint.sink{ userWaypoint in
        if let userWaypoint = userWaypoint{
            print("hello from ViewModel")
            DispatchQueue.main.async{
                let newWaypoint = CoordinateData(coordinates: userWaypoint.coordinate)
                self.waypoints.append(newWaypoint)
            }
        }
    }
}

Solution

  • If you only want to receive one value you can use:

    func first() -> Publishers.First<Self>`
    

    Publishes the first element of a stream, then finishes.

    Because $userWaypoint publishes CLLocation? you also need to use:

    func compactMap<T>((Self.Output) -> T?) -> Publishers.CompactMap<Self, T>
    

    Calls a closure with each received element and publishes any returned optional that has a value.

    With all of the above:

    cancellable = locationManager
        .$userWaypoint
        .compactMap { $0 } // remove nils
        .first() // ensure that only one value gets emitted
        .map { CoordinateData(coordinates: $0.coordinate) } // map to the type you need
        .receive(on: RunLoop.main) // switch to the main thread
        .sink { [weak self] waypoint in
            self?.waypoints.append(waypoint)
        }