I am converting some code into Combine in order to get familiar with it. I am doing fine with the easy stuff, but here it gets a little trickier. I am trying to report to the user when incoming GPS data is accurate and also whether it's stale.
So I have
let locationPublisher = PassthroughSubject<CLLocation,Never>()
private var cancellableSet: Set<AnyCancellable> = []
var status:GPSStatus = .red //enum
and in init I have
locationPublisher
.map(gpsStatus(from:)) //maps CLLocation to GPSStatus enum
.assign(to: \.gpsStatus, on: self)
.store(in: &cancellableSet)
locationPublisher.sink() { [weak self] location in
self?.statusTimer?.invalidate()
self?.setStatusTimer()
}
.store(in: &cancellableSet)
setStatusTimer()
Here is the setStatusTimer function
func setStatusTimer () {
statusTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) {@MainActor _ in
self.updateGPSStatus(.red)
}
}
Is there a more "Combine" way of doing this? I know there are Timer.TimerPublishers, but I'm not sure how to incorporate them?
My tendency is to think there is some kind of combineLatest with one input being the gps status publisher and the other one being some kind go publisher that fires if the upstream pub hasn't fired for x seconds.
Thanks!
CodePudding user response:
This is a bit tricky. You don't need a TimerPublisher
but you can use the timeout
operator. The tricky part is that timeout
will create a publisher that stops publishing when it times out. The question is "how do you start again". To do that, you can use the catch
operator.
The solution looks like this:
import UIKit
import Combine
enum GPSStatus {
// Karma Chameleon...
case red
case gold
case green
}
func gpsStatus(from location: String) -> GPSStatus {
switch location {
case "ok":
return .green
default:
return .gold
}
}
class UnnamedLocationThingy {
let locationPublisher = PassthroughSubject<String,Never>()
var gpsStatus:GPSStatus = .red {
didSet { print("set the new value to \(String(describing: gpsStatus))") }
}
private enum LocationError: Error {
case timeout
}
private var statusProvider: AnyCancellable?
init() {
statusProvider = makeStatusProvider()
.assign(to: \.gpsStatus, on: self)
}
func makeStatusProvider() -> AnyPublisher<GPSStatus, Never> {
return locationPublisher
.map(gpsStatus(from:)) //maps CLLocation to GPSStatus enum
.setFailureType(to: LocationError.self)
.timeout(.seconds(2), scheduler: DispatchQueue.main) {
return LocationError.timeout
}
.catch { _ in
self.gpsStatus = .red;
return self.makeStatusProvider()
}.eraseToAnyPublisher()
}
}
let thingy = UnnamedLocationThingy();
Task {
for delay in [1, 1, 1, 3, 1, 1, 3, 1] {
try await Task.sleep(for: .seconds(delay))
thingy.locationPublisher.send("ok")
}
}
The heart of it is the makeStatusProvider
function. This function creates a publisher that will convert published locations to GPSStatus
values as long as they come in in a time limit. But if one doesn't come fast enough, it times out. I set up the timeout
operator with a customError:
handler so that it doesn't just terminate the publisher, but sends an error. I can catch that error in the catch
operator and substitute a new publisher to replace the old one. The new publisher I substitute is a brand new publisher created by makeStatusProvider
which, as we've just seen is a publisher that converts published locations into GPSStatus
values until it hits a timeout...
It's a form of recursion.
I've decorated your code with enough stuff to make a Playground and added a bit of code at the end to exercise the functionality.