This is Understanding Combine, written by Matt Neuburg. Corrections and suggestions are greatly appreciated (you can comment here). So are donations; please consider keeping me going by funding this work at http://www.paypal.me/mattneub. Or buy my books: the current (and final) editions are iOS 15 Programming Fundamentals with Swift and Programming iOS 14. Thank you!
Timer Publisher
A Timer (Objective-C NSTimer) is an object that “fires”, emitting a signal, after a given time interval has elapsed. Often, a Timer will be a repeating Timer, meaning that it fires after every lapse of the given time interval. If you were previously using a repeating Timer, you can replace it with a Timer publisher (Timer.TimerPublisher). This publisher is vended by the Timer class:
static func publish(
every: TimeInterval, tolerance: TimeInterval? = nil,
on: RunLoop, in: RunLoop.Mode)
The usual RunLoop is .main
(the run loop of the main thread), and the usual Mode is .common
.
A Timer publisher is rather different from the publishers we’ve talked about so far; it is a ConnectablePublisher. That means that merely subscribing to it is not sufficient to schedule the timer and start it firing. To make those things happen, you must tell the publisher to connect
. To do so, you have two choices:
Apply the .autoconnect()
operator to the publisher. This causes connect()
to be sent automatically to the publisher when it is subscribed to.
Maintain a reference to the publisher and send it the connect()
message manually.
To stop the timer, you have two choices as well:
Cancel the subscriber.
If you called connect()
on the timer publisher, that call returned a Cancellable object. If you kept a reference to that object, you can send cancel()
to it. Moreover, a Cancellable implements store(in:)
, so you can wrap it in an AnyCancellable that you can retain and send cancel()
to.
For example, in one of my apps, I have this code, changing a progress view to track the currently playing song:
self.timer = Timer.scheduledTimer(
timeInterval: 0.5,
target: self, selector: #selector(checkFraction),
userInfo: nil, repeats: true)
The timer fires by calling a method checkFraction
, which responds by examining the duration of the currently playing song and the current playback time of the music player, and sets a progress view’s value to their ratio.
I can replace the timer with a timer publisher; I might do it like this:
var timerCancellable = Set<AnyCancellable>()
func makeTimer() {
self.timerCancellable.first?.cancel()
let timerPublisher = Timer.publish(every: 0.5, on: .main, in: .common)
let timerPipeline =
Subscribers.Sink<Date,Never>(receiveCompletion:{_ in}) {
[unowned self] _ in self.checkFraction()
}
timerPublisher.subscribe(timerPipeline)
timerPublisher.connect()
.store(in:&self.timerCancellable)
}
In that code, I’ve demonstrated the second strategy for starting and stopping the timer. In makeTimer
, I’ve kept a reference to the timer publisher long enough to send connect()
to it; and in the timerCancellable
instance property, I’ve kept a reference to the Cancellable object returned from that connect()
call, wrapped up in an AnyCancellable. If I need to cancel the timer manually, I can say self.timerCancellable.removeAll()
. Moreover, our reference to self
in the sink’s receive value function is marked unowned
, to prevent retain cycles; so the timer publisher will be cancelled, the timer will stop, and the whole pipeline will be torn down, automatically, when my view controller goes out of existence.
The value emitted by the timer publisher when it fires is the current date–time (as if you had called Date()
). This may seem arbitrary, but really it’s no better or worse than what a Timer supplies, namely a reference to itself. The purpose of the timer is to fire, plain and simple, by emitting a value, and what value it emits is not usually very important. You’ll notice that in the preceding code I ignored the incoming date completely.
Moreover, if precise timing is important to you, the current date–time is exactly what you want to know, so that you can compare it to the previous value to derive the exact elapsed interval. Here’s an example:
Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.scan((prev:Date.distantPast, now:Date())) { (prev:$0.now, now:$1) }
.map { $0.now.timeIntervalSince($0.prev) }
.sink { print($0) }
.store(in: &storage)
The value that arrives at the end of that pipeline is the amount of time elapsed since the timer last fired.