PersistableTimer is a Swift library that provides persistent timers and stopwatches with seamless state restoration — even across app restarts. It supports both countdown timers and stopwatches, with flexible data sources such as UserDefaults (for production) and in-memory storage (for testing or previews).
- Persistent State: Restore timer state automatically after app termination or restart.
- Dual Modes: Choose between a running stopwatch and a countdown timer.
- Real-time Updates: Subscribe to continuous timer updates via an asynchronous stream.
- Dynamic Time Adjustment: Add extra time to a countdown or extra elapsed time to a stopwatch.
- SwiftUI Integration: Easily display timer states using extensions from
PersistableTimerText.
See the Example App for a complete SwiftUI implementation.
Add the package dependency in your Package.swift:
dependencies: [
.package(url: "https://github.com/Ryu0118/swift-persistable-timer.git", from: "0.7.0")
],Then add the desired products (PersistableTimer, PersistableTimerCore, or PersistableTimerText) to your target dependencies.
import PersistableTimer
// For production (with persistence):
let timer = PersistableTimer(dataSourceType: .userDefaults(.standard))
// For testing or previews:
let timer = PersistableTimer(dataSourceType: .inMemory)// Start a stopwatch
try await timer.start(type: .stopwatch)
// Pause and resume
try await timer.pause()
try await timer.resume()
// Add extra elapsed time (moves start date back by 5 seconds)
try await timer.addElapsedTime(5)
// Finish the stopwatch
try await timer.finish()// Start a 100-second countdown
try await timer.start(type: .timer(duration: 100))
// Add extra time to the countdown
try await timer.addRemainingTime(30) // Now 130 seconds total
// Force restart even if already running
try await timer.start(type: .timer(duration: 60), forceStart: true)import SwiftUI
import PersistableTimerText
struct TimerView: View {
@State private var timerState: TimerState?
let timer = PersistableTimer(dataSourceType: .userDefaults(.standard))
var body: some View {
VStack {
// Automatic timer display
Text(timerState: timerState)
.font(.largeTitle)
HStack {
Button("Start") {
Task { try? await timer.start(type: .timer(duration: 60)) }
}
Button("Pause") {
Task { try? await timer.pause() }
}
Button("Resume") {
Task { try? await timer.resume() }
}
}
}
.onAppear {
// Restore timer state after app restart
try? timer.restore()
}
}
}Use unique IDs to manage multiple timers simultaneously:
let workoutTimer = PersistableTimer(
id: "workout",
dataSourceType: .userDefaults(.standard)
)
let restTimer = PersistableTimer(
id: "rest",
dataSourceType: .userDefaults(.standard)
)
// Each timer maintains its own state
try await workoutTimer.start(type: .timer(duration: 300))
try await restTimer.start(type: .timer(duration: 60))let timer = PersistableTimer(
id: "custom",
dataSourceType: .userDefaults(.standard),
shouldEmitTimeStream: true, // Enable real-time updates
updateInterval: 0.1, // Update every 100ms (default: 1s)
useFoundationTimer: false, // Use AsyncTimerSequence (default)
now: { Date() } // Custom date provider
)// Check if timer is running
if timer.isTimerRunning() {
print("Timer is active")
}
// Get current timer data
if let data = try? timer.getTimerData() {
print("Started at: \(data.startDate)")
print("Type: \(data.type)")
}
// Subscribe to updates
for await state in timer.timeStream {
print("Elapsed: \(state.elapsedTime)s")
print("Status: \(state.status)")
switch state.type {
case .stopwatch:
print("Stopwatch time: \(state.time)s")
case .timer(let duration):
print("Remaining: \(state.time)s / \(duration)s")
}
}do {
try await timer.start(type: .stopwatch)
} catch PersistableTimerClientError.timerAlreadyStarted {
print("Timer is already running")
} catch {
print("Failed to start timer: \(error)")
}
// Common errors:
// - .timerAlreadyStarted: Cannot start when already running (use forceStart: true)
// - .timerAlreadyPaused: Cannot pause when already paused
// - .timerHasNotPaused: Cannot resume when not paused
// - .timerHasNotStarted: Cannot perform operation on non-existent timer
// - .invalidTimerType: Wrong operation for timer type (e.g., addRemainingTime on stopwatch)| Method | Description |
|---|---|
start(type:forceStart:) |
Start a new timer (stopwatch or countdown) |
pause() |
Pause the running timer |
resume() |
Resume a paused timer |
finish(isResetTime:) |
Finish the timer, optionally resetting elapsed time |
restore() |
Restore timer state after app restart |
addElapsedTime(_:) |
Add elapsed time to stopwatch (moves start date back) |
addRemainingTime(_:) |
Add time to countdown timer |
isTimerRunning() |
Check if timer is currently active |
getTimerData() |
Get the current timer data |
TimerState - Complete timer state with computed properties
elapsedTime: TimeInterval- Total elapsed time (adjusted for pauses)status: TimerStatus-.running,.paused, or.finishedtype: RestoreType-.stopwatchor.timer(duration:)time: TimeInterval- Elapsed time for stopwatch, remaining for countdowndisplayDate: Date- Date for UI display
RestoreType - Timer operation mode
.stopwatch- Count up from zero.timer(duration: TimeInterval)- Count down from duration
DataSourceType - Storage backend
.userDefaults(UserDefaults)- Persistent storage.inMemory- Temporary storage (testing/previews)
Text(timerState: timerState, countsDown: true)Automatically displays and updates timer in MM:SS format. Shows --:-- when timerState is nil.
This project is licensed under the MIT License. See the LICENSE file for details.