swiftswiftuitimer

Why is increasing the "resolution" of a timer actually decreasing accuracy?


The central part of my App is a timer. Thus far this was the Code (taken out of the main Codebase to be minimum reproducible):

import SwiftUI

struct TestView: View {

    @State var countdownTimer = 5 * 60      /// The actual seconds of the timer, being counted down/up
    @State var timerRunning = false         /// Var to set and see if timer is running
    
    var body: some View {
        /// For formatting the timer numbers
        let style = Duration.TimeFormatStyle(pattern: .minuteSecond)
        let formTimer = Duration.seconds(countdownTimer).formatted()
        let formTimerShort = Duration.seconds(countdownTimer).formatted(style)
        
        /// The main Timer If there are no hours we hide the hour number and make the timer bigger in this way.
        Text(countdownTimer / 3600 == 0 && timerRunning ? "\(formTimerShort)" : "\(formTimer)")
            .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
                    if countdownTimer > 0 && timerRunning {
                        countdownTimer -= 1
                    } else {
                        timerRunning = false
                    }
            }
        
        /// The Start and Pause buttons
        HStack {
            if !timerRunning {
                Button(action: {
                    timerRunning = true
                }) {
                    Text("Start")
                        .frame(width: 300, height: 75)
                        .font(.system(size: 60))
                }
            } else {
                Button(action: {
                    timerRunning = false
                }) {
                    Text("Pause")
                        .frame(width: 300, height: 75)
                        .font(.system(size: 60))
                }
            }
        }
    }
}

#Preview {
    TestView()
}

The problem with this was that it was not accurate. About 40 seconds of in 1 hour of timer usage. So my idea was to change when the Timer is published from 1 to 0.001, thus increasing the resolution. This Code works, but it is even less accurate!

struct TestView: View {

    @State var countdownTimer = 5 * 60      /// The actual seconds of the timer, being counted down/up
    @State private var fractionTimer = 0    /// Helping us to make the timer more accurate
    @State var timerRunning = false         /// Var to set and see if timer is running
    
    var body: some View {
        [...]
        
        /// The main Timer If there are no hours we hide the hour number and make the timer bigger in this way.
        Text(countdownTimer / 3600 == 0 && timerRunning ? "\(formTimerShort)" : "\(formTimer)")
            .onReceive(Timer.publish(every: 0.001, on: .main, in: .common).autoconnect()) { _ in
                /// Count up the fraction timer, only if he hits a certain Number we count up our Visible Timer
                if timerRunning {
                    fractionTimer += 1
                }
                
                if fractionTimer == 1000 {
                    if countdownTimer > 0 && timerRunning {
                        countdownTimer -= 1
                        fractionTimer = 0
                    } else {
                        timerRunning = false
                        fractionTimer = 0
                    }
                }
            }

        [...]
    }
}

According to my logic this should have taken the inaccuracy from 40 seconds per hour to 0.04 seconds per hour. But actually it is now 120 seconds per hour off!

How can this be? Is it because it is running on the main thread and because it is called 1000 times a second there are many other tasks that will delay that call? Should I take it from the main thread?


Solution

  • I haven't worked much with timers in SwiftUI, but in gereral, you don't want to rely on timers to give you the exact number of calls that you ask for. (They will occasionally "skip a beat" if the system is busy at the moment the timer was supposed to fire.) You should use a Timer to update your UI, and then use math with the current Date and an end Date object to calculate the amount of time that has elapsed. (So go back to a timer that fires once/second, or a few times a second, but calculate the number of seconds remaining based on the current Date each time the timer fires.)

    Consider this variation of your code, which will not lose seconds:

    
    import SwiftUI
    
    struct ContentView: View {
        @State var startDate = Date()       /// For a countUp timer, the date the timer was started
        @State var endDate = Date()         /// For a count down timer, the Date when the timer will end.
        var totalSeconds: Int               /// For a countdown timer, the number of seconds to count.
        @State var timerSeconds: Int        /// The actual seconds of the timer, being counted down/up
        @State var timerRunning = false     /// Var to set and see if timer is running
        @State var countUp: Bool = false    /// True if the timer is counting up, false if counting down.
    
        init(totalSeconds: Int, countUp: Bool = false) {
            self.totalSeconds = totalSeconds
            self.countUp = countUp
            if countUp {
                self.timerSeconds = 0
            } else {
                self.timerSeconds = totalSeconds
            }
        }
        
        var body: some View {
            VStack(alignment: .center) {
                Spacer()
                /// For formatting the timer numbers
                let style = Duration.TimeFormatStyle(pattern: .minuteSecond)
                let formTimer = Duration.seconds(timerSeconds).formatted()
                let formTimerShort = Duration.seconds(timerSeconds).formatted(style)
                
                /// The main Timer If there are no hours we hide the hour number and make the timer bigger in this way.
                Text(timerSeconds < 3600 ? "\(formTimerShort)" : "\(formTimer)")
                    .font(.system(size: 42))
                    .bold()
                    .foregroundColor(countUp || timerSeconds > 0 ? .blue : .red)
                    .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
                        if timerRunning {
                            if countUp {
                                timerSeconds = Int(round(Date().timeIntervalSinceReferenceDate) - startDate.timeIntervalSinceReferenceDate)
    
                            } else {
                                timerSeconds = Int(round(endDate.timeIntervalSinceReferenceDate - Date().timeIntervalSinceReferenceDate))
                                if timerSeconds <= 0 {
                                    timerRunning = false
                                } else {
                                    timerSeconds = Int(round(endDate.timeIntervalSinceReferenceDate - Date().timeIntervalSinceReferenceDate))
                                }
                            }
                        }
                    }
                    .onChange(of: countUp) {
                        if countUp {
                            timerSeconds = 0
                        } else {
                            timerSeconds = totalSeconds
                        }
                    }
                /// The Start and Pause buttons
                if !timerRunning {
                    Button(action: {
                        if countUp {
                            startDate = Date().addingTimeInterval(Double(-timerSeconds))
                        } else {
                            endDate = Date().addingTimeInterval(Double(timerSeconds))
                        }
                        timerRunning = true
                    }) {
                        Text("Start")
                            .frame(width: 300, height: 75)
                            .font(.system(size: 60))
                    }
                } else {
                    Button(action: {
                        timerRunning = false
                    }) {
                        Text("Pause")
                            .frame(width: 300, height: 75)
                            .font(.system(size: 60))
                    }
                }
                
                Button(action: {
                    if countUp {
                        timerSeconds = 0
                        startDate = Date()
                    } else {
                        timerSeconds = totalSeconds
                        endDate = Date().addingTimeInterval(Double(timerSeconds))
                    }
                }) {
                    Text("Reset")
                        .frame(width: 300, height: 75)
                        .font(.system(size: 60))
                }
                Spacer()
                HStack {
                    Spacer()
                    Toggle("Count up", isOn: $countUp)
                        .frame(width: 150)
                        .disabled(timerRunning)
                    Spacer()
                }
            }
        }
    }
    
    
    #Preview {
        ContentView(totalSeconds: 5 * 60, countUp: false)
    }