SwiftUI - How to Make A Start/Stop Timer
Here is a fixed version. Take a look at the changes I made.
.onReceive
now updates atimerString
if the timer is running. The timeString is the interval between now (ie.Date()
) and thestartTime
.- Tapping on the timer sets the
startTime
if it isn't running.
struct TimerView: View {
@State var isTimerRunning = false
@State private var startTime = Date()
@State private var timerString = "0.00"
let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
var body: some View {
Text(self.timerString)
.font(Font.system(.largeTitle, design: .monospaced))
.onReceive(timer) { _ in
if self.isTimerRunning {
timerString = String(format: "%.2f", (Date().timeIntervalSince( self.startTime)))
}
}
.onTapGesture {
if !isTimerRunning {
timerString = "0.00"
startTime = Date()
}
isTimerRunning.toggle()
}
}
}
The above version, while simple, bugs me that the Timer
is publishing all the time. We only need the Timer
publishing when the timer is running.
Here is a version that starts and stops the Timer
:
struct TimerView: View {
@State var isTimerRunning = false
@State private var startTime = Date()
@State private var timerString = "0.00"
@State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
Text(self.timerString)
.font(Font.system(.largeTitle, design: .monospaced))
.onReceive(timer) { _ in
if self.isTimerRunning {
timerString = String(format: "%.2f", (Date().timeIntervalSince( self.startTime)))
}
}
.onTapGesture {
if isTimerRunning {
// stop UI updates
self.stopTimer()
} else {
timerString = "0.00"
startTime = Date()
// start UI updates
self.startTimer()
}
isTimerRunning.toggle()
}
.onAppear() {
// no need for UI updates at startup
self.stopTimer()
}
}
func stopTimer() {
self.timer.upstream.connect().cancel()
}
func startTimer() {
self.timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
}
}
SwiftUI: How to cancel timer in SwiftUI view?
Inside your conditional statement, use the following code:
self.timer.upstream.connect().cancel()
SwiftUI - Timer doesn't stop at 0
Set the timer in the onAppear
and invalidate the timer
when endDate
and currentDate
align together.
struct CV: View {
@State var currentDate: Date = Date()
@State var timer: Timer?
var endDate = Calendar.current.date(byAdding: .minute, value: 1, to: Date())!
var body: some View {
Text(countdownString(to: endDate))
.font(.headline)
.fontWeight(.bold)
.foregroundColor(.green)
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.currentDate = Date()
}
}
}
func countdownString(to date: Date) -> String {
let calendar = Calendar(identifier: .gregorian)
let components = calendar.dateComponents([.hour, .minute, .second], from: currentDate, to: endDate)
if currentDate >= endDate {
timer?.invalidate()
timer = nil
}
return String(format: "%02d hours : %02d minutes : %02d seconds",
components.hour ?? 00,
components.minute ?? 00,
components.second ?? 00)
}
}
How to stop timer in Text view?
Here is a demo of possible approach - as .timer
run from now for ever (by design), the idea is to replace it with regular text once specified period is over.
Tested with Xcode 12b3 / iOS 14.
struct DemoView: View {
@State private var run = false
var body: some View {
VStack {
if run {
Text(nextRollTime(in: 10), style: .timer)
} else {
Text("0:00")
}
}
.font(Font.system(.title, design: .monospaced))
.onAppear {
self.run = true
}
}
func nextRollTime(in seconds: Int) -> Date {
let date = Calendar.current.date(byAdding: .second, value: seconds, to: Date())
DispatchQueue.main.asyncAfter(deadline: .now() + Double(seconds)) {
self.run = false
}
return date ?? Date()
}
}
SwiftUI: How to run a Timer in background
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.
Related Topics
How Safe Are Swift Collections When Used with Invalidated Iterators/Indices
Nsurlsession/Nsurlconnection Http Load Failed (Kcfstreamerrordomainssl, -9802)
How to Use Environment Map in Arkit
Sending Whatsapp Message to a Specific Contact Number (Swift Project)
How to Check If an Email Address Is Already in Use Firebase
Can't Able to Get Video Tracks from Avurlasset for Hls Videos(.M3U8 Format) for Avplayer
Using Key-Value Programming (Kvp) with Swift
Load a Pcm into a Avaudiopcmbuffer
Swift: Deallocate Gamescene After Transition to New Scene
How to Get the Yaw, Pitch, Roll of an Aranchor in Absolute Terms
Arkit - How to Put 3D Object on Qrcode
Scenekit -- How to Get Animations for a .Dae Model
Variable Captured by Closure Before Being Initialized