SwiftUI: How to run a Timer in background

Viewed 3335

I have built a basic timer app and I'm trying to figure out on how to run the timer in background. I have tried the Background Mode from Signing and Capabilities and doesn't seem to work out for me.

I'm currently working on Xcode 12 beta 6.

Code

struct ContentView: View {
    @State var start = false
    @State var count = 0
    
    var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        ZStack {
            // App content.
        }
        .onReceive(timer, perform: { _ in
            if start {
                if count < 15 {
                    count += 1
                } else {
                    start.toggle()
                }
            }
        })
    }
}

If anyone of you have any suggestions on managing the timer in a better way, please do let me know. Thanks.

2 Answers

When user leaves the app, it is suspended. One generally doesn’t keep timers going when the user leaves the app. We don't want to kill the user’s battery to update a timer that really isn’t relevant until the user returns to the app.

This obviously means that you do not want to use the “counter” pattern. Instead, capture the the Date when you started the timer, and save it in case the user leaves the app:

func saveStartTime() {
    if let startTime = startTime {
        UserDefaults.standard.set(startTime, forKey: "startTime")
    } else {
        UserDefaults.standard.removeObject(forKey: "startTime")
    }
}

And, when the app starts, retrieved the saved startTime:

func fetchStartTime() -> Date? {
    UserDefaults.standard.object(forKey: "startTime") as? Date
}

And your timer should now not use a counter, but rather calculate the elapsed time between the start time and now:

let now = Date()
let elapsed = now.timeIntervalSince(startTime)

guard elapsed < 15 else {
    self.stop()
    return
}

self.message = String(format: "%0.1f", elapsed)

Personally, I'd abstract this timer and persistence stuff out of the View:

class Stopwatch: ObservableObject {
    /// String to show in UI
    @Published private(set) var message = "Not running"

    /// Is the timer running?
    @Published private(set) var isRunning = false

    /// Time that we're counting from
    private var startTime: Date?                        { didSet { saveStartTime() } }

    /// The timer
    private var timer: AnyCancellable?

    init() {
        startTime = fetchStartTime()

        if startTime != nil {
            start()
        }
    }
}

// MARK: - Public Interface

extension Stopwatch {
    func start() {
        timer?.cancel()               // cancel timer if any

        if startTime == nil {
            startTime = Date()
        }

        message = ""

        timer = Timer
            .publish(every: 0.1, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                guard
                    let self = self,
                    let startTime = self.startTime
                else { return }

                let now = Date()
                let elapsed = now.timeIntervalSince(startTime)

                guard elapsed < 60 else {
                    self.stop()
                    return
                }

                self.message = String(format: "%0.1f", elapsed)
            }

        isRunning = true
    }

    func stop() {
        timer?.cancel()
        timer = nil
        startTime = nil
        isRunning = false
        message = "Not running"
    }
}

// MARK: - Private implementation

private extension Stopwatch {
    func saveStartTime() {
        if let startTime = startTime {
            UserDefaults.standard.set(startTime, forKey: "startTime")
        } else {
            UserDefaults.standard.removeObject(forKey: "startTime")
        }
    }

    func fetchStartTime() -> Date? {
        UserDefaults.standard.object(forKey: "startTime") as? Date
    }
}

Then the view can just use this Stopwatch:

struct ContentView: View {
    @ObservedObject var stopwatch = Stopwatch()

    var body: some View {
        VStack {
            Text(stopwatch.message)
            Button(stopwatch.isRunning ? "Stop" : "Start") {
                if stopwatch.isRunning {
                    stopwatch.stop()
                } else {
                    stopwatch.start()
                }
            }
        }
    }
}

FWIW, UserDefaults probably isn't the right place to store this startTime. I'd probably use a plist or CoreData or whatever. But I wanted to keep the above as simple as possible to illustrate the idea of persisting the startTime so that when the app fires up again, you can make it look like the timer was running in the background, even though it wasn’t.

import SwiftUI

struct BackgroundTimer: View {
    @State var start = false
    @State var count = 0
        
    var body: some View {
        VStack {
            Text(count.description)
            Text("Timer isRunning == \(start.description)")
            Button(action: {
                start = true
                count = 0
                Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
                    count += 1
                    
                    if count == 15 {
                        timer.invalidate()
                        start = false
                    }
                }
            }, label: {
                Text("Start Timer")
            })
        }
        
    }
}

struct BackgroundTimer_Previews: PreviewProvider {
    static var previews: some View {
        BackgroundTimer()
    }
}
Related