20

My goal is to create a view in SwiftUI that starts with 0. When you press the view, a timer should start counting upwards, and tapping again stops the timer. Finally, when you tap again to start the timer, the timer should begin at 0.

Here is my current code:

import SwiftUI

struct TimerView: View {
    @State var isTimerRunning = false
    @State private var endTime = Date()
    @State private var startTime =  Date()
    let timer = Timer.publish(every: 0.001, on: .main, in: .common).autoconnect()
    
    var tap: some Gesture {
        TapGesture(count: 1)
            .onEnded({
                isTimerRunning.toggle()
            })
    }

    var body: some View {

        Text("\(endTime.timeIntervalSince1970 - startTime.timeIntervalSince1970)")
            .font(.largeTitle)
            .gesture(tap)
            .onReceive(timer) { input in
                startTime = isTimerRunning ? startTime : Date()
                endTime = isTimerRunning ? input : endTime
            }

    }
}

This code causes the timer to start instantly and never stop, even when I tap on it. The timer also goes backward (into negative numbers) rather than forward.

Can someone please help me understand what I am doing wrong? Also, I would like to know if this is a good overall strategy for a timer (using Timer.publish).

Thank you!

YulkyTulky
  • 886
  • 1
  • 6
  • 20

3 Answers3

32

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()
    }
}
vacawama
  • 150,663
  • 30
  • 266
  • 294
  • 2
    Thank you! This works well. I am just beginning to learn SwiftUI (and Swift) and this code has already helped me grasp some concepts. Now I just have to figure out how to use the times from my ContentView – YulkyTulky Aug 23 '20 at 16:24
  • If running this code on Swift 5.3 or later, the use of self throughout is no longer needed. – Marcy Dec 05 '22 at 20:36
2

Stop-watch Timer

The following approach allows you create a start/stop/reset SwiftUI Timer using @Published and @ObservedObject property wrappers, along with the ObservableObject protocol.

Here's the ContentView structure:

import SwiftUI

struct ContentView: View {
    
    @ObservedObject var stopWatch = Stop_Watch()
    
    var body: some View {
                
        let minutes = String(format: "%02d", stopWatch.counter / 60)
        let seconds = String(format: "%02d", stopWatch.counter % 60)
        let union = minutes + " : " + seconds
        
        ZStack {
            Color.black.ignoresSafeArea()
            VStack {
                Spacer()
                HStack {
                    Button("Start") { self.stopWatch.start() }
                        .foregroundColor(.purple)
                    Button("Stop") { self.stopWatch.stop() }
                        .foregroundColor(.orange)
                    Button("Reset") { self.stopWatch.reset() }
                        .foregroundColor(.yellow)
                }
                Spacer()
                Text("\(union)")
                    .foregroundColor(.teal)
                    .font(.custom("", size: 90))
                Spacer()
            }
        }
    }
}

...and Stop_Watch class:

class Stop_Watch: ObservableObject {
    
    @Published var counter: Int = 0
    var timer = Timer()
    
    func start() {
        self.timer = Timer.scheduledTimer(withTimeInterval: 1.0,
                                                   repeats: true) { _ in
            self.counter += 1
        }
    }
    func stop() {
        self.timer.invalidate()
    }
    func reset() {
        self.counter = 0
        self.timer.invalidate()
    }
}

enter image description here

Andy Jazz
  • 49,178
  • 17
  • 136
  • 220
  • The problem with this is that the timer will pause if the state of the view changes, for example, I have one in a scrollview, and when you scroll, the timer stops calling until scrolling is over. – Trevor Jan 05 '23 at 21:38
  • Have you tried vacawama's timer? – Andy Jazz Jan 05 '23 at 22:02
  • Yeah, that one works great and will not be disrupted by the view state – Trevor Jan 05 '23 at 22:30
1

Updated for Swift 5.7 and iOS 16 to display a timer that counts up seconds and minutes like a simple stopwatch. Using DateComponentsFormatter to format the minutes and seconds.

struct StopWatchView: View {
    @State var isRunning = false
    @State private var startTime = Date()
    @State private var display = "00:00"
    @State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    var body: some View {
      Text(display)
        .font(.system(size: 20, weight: isRunning ? .bold : .light, design: .monospaced))
        .foregroundColor(.accentColor)
        .onReceive(timer) { _ in
          if isRunning {
            let duration = Date().timeIntervalSince(startTime)
            let formatter = DateComponentsFormatter()
            formatter.allowedUnits = [.minute, .second]
            formatter.unitsStyle = .positional
            formatter.zeroFormattingBehavior = .pad
            display = formatter.string(from: duration) ?? ""
          }
        }
        .onTapGesture {
          if isRunning {
            stop()
          } else {
            display = "00:00"
            startTime = Date()
            start()
          }
          isRunning.toggle()
        }
        .onAppear {
          stop()
        }
    }
    
    func stop() {
      timer.upstream.connect().cancel()
    }
    func start() {
      timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    }
  }


  struct StopWatchView_Previews: PreviewProvider {
    static var previews: some View {
      StopWatchView()
    }
  }
John Pavley
  • 5,366
  • 2
  • 14
  • 16