Changing a State var in a Timer block doesn't update the UI

Can anyone explain to me why my UI doesn't update after the timer fires in this code below? Even when the timer fires, the UI doesn't update.

thanks, in advance for any guidance

Mike

struct ContentView: View {
    @State var referenceDate: Date = Date()
    init () {
        setupTimer()
    }
    
    func setupTimer() {
        let calendar = Calendar.current
        guard let triggerDate = calendar.nextDate(
            after: Date(),
            matching: DateComponents(hour: 14, minute: 46, second: 0),
            matchingPolicy: .nextTime
        ) else { return }

        let timer = Timer(fire: triggerDate, interval: 0.2, repeats: false) { _ in
            self.referenceDate = Date()
            print("runLoop!")
        }
        RunLoop.main.add(timer, forMode: .common)
    }

    var body: some View {
        VStack {
            Text("Ref time: \(referenceDate.formatted(date: .abbreviated, time: .standard))")
        }
    }
}

So... bit of a mind-bender, but you're losing your "self" reference, and possibly leaking timers into the runloop.

SwiftUI views are not actually views, but more like view-blueprints, that can be asked for a view. This means they can be inited as needed by the system and body can be called as needed.

This means your setupTimer method is at risk of getting called multiple times and timers could keep getting put into the run loop. It also means that the "self" reference inside the timer closure is super dubious.

Throwing it into an observable controller that's stored as a state object property will keep the state more constant and it will work.

(I'm leery of the .task method in this example and it's just to get the timer started. You probably want debounce protection if you're gonna store em in the runLoop. You should probably seek an alternative async/await solution for that)

@Observable
class Controller {
    var referenceDate: Date = Date()
    
    init() {
    }
    
    func setupTimer() {
        let calendar = Calendar.current
        guard let triggerDate = calendar.nextDate(
            after: Date(),
            matching: DateComponents(hour: 20, minute: 16, second: 0),
            matchingPolicy: .nextTime
        ) else { return }
        
        let timer = Timer(fire: triggerDate, interval: 0.2, repeats: false) { _ in
            DispatchQueue.main.async {
                self.referenceDate = Date()
            }
            print("runLoop!")
        }
        RunLoop.main.add(timer, forMode: .common)
    }
}

struct ContentView: View {
    @State var controller = Controller()

    var body: some View {
        VStack {
            Text("Ref time: \(controller.referenceDate.formatted(date: .abbreviated, time: .standard))")
            
            Button("Huh") {
                controller.referenceDate = Date()
            }
        }
        .task {
            controller.setupTimer()
        }
    }
}

J0hn is absolutely right that Timer and SwiftUI aren’t really best friends. Timer comes from the Objective-C world, and SwiftUI is not built on that *cough*… foundation. So, in a SwiftUI app I try to avoid Timer and instead lean in to Swift concurrency primitives.

For example, if you want a simple repeating timer you can use a loop containing a Task.sleep(…):

struct ContentView: View {
    var body: some View {
        … your view …
        .task {
            do {
                while true {
                    … do work …
                    try await Task.sleep(for: .seconds(5))
                }
            } catch is CancellationError {
                return
            } catch {
                … handle ‘real’ errors …
            }
        }
    }
}

For more complex stuff, you have various options in the Swift AsyncAlgorithms package, for example, the AsyncTimerSequence type.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Changing a State var in a Timer block doesn't update the UI
 
 
Q