Stop using Timer.publish
in your SwiftUI views
Create a reusable view modifier instead
If you ever try to figure out how to use Foundation’s Timer
in SwiftUI, the first thing you’ll see will be something like this:
struct CurrentDateView: View {
@State private var currentDate = Date.now
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
Text(currentDate.formatted(date: .numeric, time: .standard))
.onReceive(timer) { input in
currentDate = input
}
}
}
You create a Combine publisher directly via Timer.publish
function, and then “listen” to it with the onReceive
modifier on your view.
And while there’s nothing particularly wrong with this (it works exactly as it should), I’d argue there’s a better way to achieve this.
Two things that are imperfect about this solution, in my opinion, are:
- Because of all the peculiarities of
Timer
publisher (remembering what run loop modes to use, and remembering theautoconnect()
call), the API feels unintuitive and you’ll likely end up googling this snippet every time. - Having a timer publisher as a free-standing property of your
View
adds unnecessary clutter and complexity for something that should be quite simple, and feels out of place in declarative SwiftUI code.
Solution: reusable TimerModifier
A much better solution is to encapsulate this logic in a ViewModifier
that you can then easily reuse in any part of your app. The end result will look like this:
struct CurrentDateView: View {
@State private var currentDate = Date.now
var body: some View {
Text(currentDate.formatted(date: .numeric, time: .standard))
.onTimer(every: 1) { date in
currentDate = date
}
}
}
Doesn’t it look and feel much cleaner?
The implementation itself is also trivial, because we’re basically encapsulating our publisher logic within a separate ViewModifier
struct TimerModifier: ViewModifier {
@State private var timer: Publishers.Autoconnect<Timer.TimerPublisher>
private let perform: (Date) -> Void
init(
every interval: TimeInterval,
tolerance: TimeInterval?,
perform: @escaping (Date) -> Void
) {
self.timer = Timer.publish(every: interval, tolerance: tolerance, on: .main, in: .common)
.autoconnect()
self.perform = perform
}
func body(content: Content) -> some View {
content
.onReceive(timer) { date in
self.perform(date)
}
}
}
extension View {
func onTimer(
every interval: TimeInterval,
tolerance: TimeInterval? = nil,
perform: @escaping (Date) -> Void
) -> some View {
self.modifier(
TimerModifier(
every: interval,
tolerance: tolerance,
perform: perform
)
)
}
}
// usage:
Text(currentDate.formatted(date: .numeric, time: .standard))
.onTimer(every: 1, tolerance: 0.05) { date in
currentDate = date
}
As you see, we can also easily supply tolerance
to our timer with this modifier.
In my opinion, this is one of the under-appreciated features of the ViewModifier
— they can contain their own internal @State
and all other SwiftUI observations, and can nicely encapsulate complicated logic.
Now you can use .onTimer
API in any part of your app, without ever needing to look up how to use it, or caring about the intricacies of its Combine API.
Bonus 1: Cancellation
Cancelling a Timer publisher is even more of a hassle because you need to remember this exciting line:
timer.upstream.connect().cancel()
To incorporate cancellations into our onTimer
modifier, we will use something called a “Trigger value pattern”. It’s used in a bunch of Apple’s own APIs and you can find a great overview of it here: Trigger value pattern in SwiftUI.
In short, we will add a cancelTrigger
property to our TimerModifier
, and monitor its change via the onChange
modifier, so whenever the cancelTrigger
value changes, we will use it to cancel the underlying Timer.
struct TimerModifier: ViewModifier {
@State private var timer: Publishers.Autoconnect<Timer.TimerPublisher>
let cancelTrigger: AnyHashable?
private let perform: (Date) -> Void
init(
every interval: TimeInterval,
tolerance: TimeInterval?,
cancelTrigger: AnyHashable?,
perform: @escaping (Date) -> Void
) {
self.timer = Timer.publish(every: interval, tolerance: tolerance, on: .main, in: .common)
.autoconnect()
self.cancelTrigger = cancelTrigger
self.perform = perform
}
func body(content: Content) -> some View {
content
.onReceive(timer) { date in
self.perform(date)
}
.onChange(of: cancelTrigger) {
self.timer.upstream.connect().cancel()
}
}
}
extension View {
func onTimer(
every interval: TimeInterval,
tolerance: TimeInterval? = nil,
cancelTrigger: AnyHashable? = nil, // providing a trigger is optional
perform: @escaping (Date) -> Void
) -> some View {
self.modifier(
TimerModifier(
every: interval,
tolerance: tolerance,
cancelTrigger: cancelTrigger,
perform: perform
)
)
}
}
And this is how we can cancel a Timer by pressing a button
struct CurrentDateView: View {
@State private var currentDate = Date.now
@State private var cancelTimer = false
var body: some View {
VStack {
Text(currentDate.formatted(date: .numeric, time: .standard))
Button("Stop Timer") {
cancelTimer = true
}
}
.onTimer(every: 1, cancelTrigger: cancelTimer) { date in
currentDate = date
}
}
}
Of course, the timer is also automatically cancelled when the View is removed from the render tree.
Conclusion
The main idea behind this article is not just to help you simplify the usage of the Timer in SwiftUI, but to generally suggest using view modifiers to encapsulate complicated logic. We all should strive to make our views as clean and readable as possible, and creating reusable view modifiers are a great way to achieve that.
A nicely packaged and extended version of this solution is available as a GitHub gist: