Search code examples
swiftuitimer

Saving and Restoring Stopwatch State in SwiftUI When App is Closed and Reopened


I'm developing a stopwatch application using SwiftUI. The stopwatch functionality works fine while the app is running. However, I'm facing two significant issues:

If I start an activity and time is being recorded (the stopwatch is running), and then I close the app and reopen it, the stopwatch does not continue running. The expected behavior is that the stopwatch should continue timing the activity even when the app is closed and reopened.

Relatedly, when the app is reopened after being closed, the time recorded for both the current activity and the archived ones is reset to zero. Instead, I want the app to persist and display the recorded time for both ongoing and archived activities across closures of the app.

I suspect that I am not managing the persistence of the stopwatch state correctly, but I'm not sure what I'm doing wrong or how to fix it. Here's the relevant portion of my code:

struct Activity: Identifiable, Codable, Equatable {
    ...
    var startTime: Date? // This property keeps the start time
    ...
    init(from decoder: Decoder) throws {
        ...
        let startTimeKey = "startTime\(id.uuidString)"
        startTime = UserDefaults.standard.object(forKey: startTimeKey) as? Date // Here we load the start time from UserDefaults
    }

    func encode(to encoder: Encoder) throws {
        ...
        let startTimeKey = "startTime\(id.uuidString)"
        UserDefaults.standard.set(startTime, forKey: startTimeKey) // Here we save the start time to UserDefaults
    }
    ...
}

class ViewModel: ObservableObject {
    ...
    func startActivity(_ index: Int) {
        // Store the start time of the activity
        activities[index].startTime = Date()
        activities[index].isPlaying = true
        saveActivities()
    }

    func stopActivity(_ index: Int) {
        // Clear the start time of the activity
        activities[index].startTime = nil
        activities[index].isPlaying = false
        saveActivities()
    }

    func loadActivities() {
        activities = store.load()
        // For all activities that were playing when the app was closed, calculate the current time
        for index in activities.indices where activities[index].isPlaying {
            guard let startTime = activities[index].startTime else { continue }
            let timeElapsed = Date().timeIntervalSince(startTime)
            activities[index].currentTime += timeElapsed
            activities[index].startTime = Date() // Reset the start time to now
        }
        saveActivities()
    }
    ...
}

I'm looking for guidance on how to correctly save and restore the stopwatch state so that ongoing time tracking isn't lost when the app closes and is later reopened. Any insights or advice would be greatly appreciated.

I have attempted to persist the state of each stopwatch activity by saving the start time to UserDefaults whenever the activity is started or stopped. My intention was that, when activities are loaded, for any activity that was playing when the app was last closed, the elapsed time since the start time would be added to the current time of the activity, thereby continuing the stopwatch from where it left off. The start time is then reset to the current date and time.

My expectation was that, with these measures in place, the stopwatch for each activity would continue counting up from its last known state even when the app is closed and reopened. I also expected that the total duration of each activity would be properly saved and could be loaded again when the app restarts.

However, despite this approach, I've found that the stopwatch does not continue when the app is closed and reopened, and the recorded times are not being persisted as expected. The stopwatch appears to reset each time the app is restarted. I'm unsure why this is happening and how to correct these issues.


Solution

  • For the desired implementation, it is necessary to distinguish whether the stopwatch was completed by the user stopping or the app was closed.

    import SwiftUI
    
    class TimerManager: ObservableObject {
        static let shared = TimerManager()
    
        @Published var elapsedTime: Double = 0.0
    }
    
    @main
    struct stackoverflowTestApp: App {
        
        @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
        
        var body: some Scene {
            WindowGroup {
                ContentView()
            }
        }
    }
    
    class AppDelegate: NSObject, UIApplicationDelegate {
        
        func applicationWillTerminate(_ application: UIApplication) {
            let flag = UserDefaults.standard.bool(forKey: "terminateFlag")
            if flag {
                let elapsedTime = TimerManager.shared.elapsedTime
                UserDefaults.standard.set(elapsedTime, forKey: "elapsedTime")
            }
        }
    }
    

    In the code I made, I manage the terminalFlag with UserDefaults, which is a variable to determine that the app is terminated without stopping the stopwatch.

    applicationWillTerminate(_application: UIA application) is called when the app is shut down, where we save elapsedTime when the app is shut down when the stopwatch is in progress through the terminalFlag.

    Finally, onAppear initializes the saved elapsedTime if you shut down the app without stopping the stopwatch before with terminalFlag.

    import SwiftUI
    
    struct ContentView: View {
        
        @StateObject var timerManager = TimerManager.shared
        
        @State private var timer: Timer? = nil
        @State private var startTime: Date? = nil
    
        var body: some View {
            VStack {
                Text(String(format: "%.2f", timerManager.elapsedTime))
                    .padding()
    
                HStack {
                    Button {
                        UserDefaults.standard.set(true, forKey: "terminateFlag")
                        
                        self.startTime = Date()
                        self.timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { _ in
                            if let startTime = self.startTime {
                                self.timerManager.elapsedTime = Date().timeIntervalSince(startTime)
                            }
                        }
                    } label: {
                        Text("Start Time")
                    }
    
                    Spacer()
    
                    Button {
                        UserDefaults.standard.set(false, forKey: "terminateFlag")
                        
                        self.timer?.invalidate()
                        self.timer = nil
                        if let startTime = self.startTime {
                            self.timerManager.elapsedTime = Date().timeIntervalSince(startTime)
                        }
                        self.startTime = nil
                    } label: {
                        Text("Stop Time")
                    }
    
                }
                .frame(width: 200)
            }
            .onAppear {
                let flag = UserDefaults.standard.bool(forKey: "terminateFlag")
                if flag {
                    let storedElapsedTime = UserDefaults.standard.double(forKey: "elapsedTime")
                    TimerManager.shared.elapsedTime = storedElapsedTime
                }
            }
        }
    }
    

    eHere is a video testing the above code.