/ APPLE, SWIFTUI, TIMERS

SwiftUI - Using Timer for UI refreshes

At WWDC this year, one of Apple’s biggest announcements wasn’t a particular feature of the new OSes, but rather a new way to write UIs for your apps using Swift. This new framework, named SwiftUI, uses a declaritive syntax to easily define your UI in far fewer lines of code than before. Along with SwiftUI, Apple announced another framework, Combine, which helps with handling asynchronous events. This post won’t dive into the fundamentals of either of these, there are plenty of others out there already. Instead, this post is intended to work through some challenges I ran into when trying to upgrade my app, Just Timers, to use SwiftUI.

When I wrote Just Timers, I used UIKit by pushing my content into a UITableView, pretty standard. A nice feature of UITableView for an app that needs the UI to constantly refresh is the ability to simply use a timer to repeat at a given interval and run a method that refreshes your table, like so:

Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(TimerViewController.refreshCells), userInfo: nil, repeats: true)

The refreshCells method iterates over the visible cells of the table and changes various parts of the UI depending on what values the object in that cell is in. If a value changed from paused to running, the button gets updated to reflect this; if the timer expired, the UI changes color, etc. Calling the method on a reoccuring timer is easy, but updating your UI in here can get rather complex very fast.

Meanwhile, in SwiftUI, you can very easily describe your UI by breaking it into multiple views, each responsible for their own piece of the picture. You track each state inside of the given view, then when the view is updated, whatever state each piece is in gets reflected in the UI. This vastly simplifies both your UI code and keeping track of states, but it posed a new problem I wasn’t quite prepared for: Something like refreshCells doesn’t work the same way here. We don’t reference individual cells like UITableView, everything is just defined in the UI and changes when the state of that view changes.

Enter Combine. In order to utilize Timer in a way that will allow my views to “subscribe” to it firing, we need to create a wrapper class that will house our timer. Using Combine, we make this wrapper a BindableObject and add the necessary requirements. Effectively, this class will contain our timer instance, then every time the timer fires, it will call the willChange method to let subscribers know it just fired off an event.

import SwiftUI
import Combine

class TimerWrapper : ObservableObject {
    let objectWillChange = ObservableObjectPublisher()

    var timer : Timer!
    func start(withTimeInterval interval: Double = 0.1) {
        self.timer?.invalidate()
        self.timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
            self.objectWillChange.send()
        }
    }
    
    func stop() {
        self.timer?.invalidate()
    }
}

We’ll need a way to reference this timer in SwiftUI, so next we go to our SceneDelegate and update scene(…willConnectTo) to create our timer instance and pass it into the root view controller as an environment object:

if let windowScene = scene as? UIWindowScene {
    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = UIHostingController(rootView: contentView
        .environmentObject(timerObject)
        .environmentObject(timerWrapper))
    self.window = window
    window.makeKeyAndVisible()
}

Keep in mind we created our instance of TimerWrapper and have to start it (providing an interval in seconds). This instance will remain active in SceneDelegate, and by utilizing .environmentObject we can make this instance accessible anywhere in our code now.

Now, anywhere we want our UI to update with this given interval, just add this to your view:

@EnvironmentObject var timerWrapper: TimerWrapper

You don’t need to do anything else with that value. By simply referencing it in your view, the publisher will automatically nofify each view that is subscribed, causing them to redraw their view due to the state change.

Something to consider: It’s easy to think “I’ll just put this on my root view and everything will update!” Technically this is true, but in my experience it causes buttons and other interactions to become difficult to use. Instead, consider the views that will need updated specifically. In my case, the string that displays my timer’s current value is the only thing that needs updated by this timer. By putting that into its own view, you not only simplify code by separating it, but you can also reference the timer wrapper in this view only to prevent the entire UI getting bogged down.

I hope this helps anyone out trying to something similar. I didn’t come to all of this on my own, credit goes to this Stack Overflow answer and this response to my problem on Reddit.

Update 21 February 2020: SwiftUI and Combine had some growing pains through the beta periods and have changed along the way. The code snippets above have been updated with current changes, like ‘willChange’ becoming ‘objectWillChange’.

aaron

Aaron Dippner

Software engineer who loves to nerd out about technology, home automation, gadgets and everything else.

Read More