Swiftui - How to Make a Start/Stop Timer

SwiftUI - How to Make A Start/Stop Timer

Here is a fixed version. Take a look at the changes I made.

  • .onReceive now updates a timerString if the timer is running. The timeString is the interval between now (ie. Date()) and the startTime.
  • 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.

demo

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



Leave a reply



Submit