How to Resume a Published Timer in Swiftui After Navigating to a Different Page

How do I resume a published timer in SwiftUI after navigating to a different page?

Reinitializing your timer and date inside the onAppear would work:

struct ContentView: View {
@State private var timer = Timer.publish(every: 1, on: .main, in: .common)
@State var time = Date()

var body: some View {
TabView {
VStack {
Text("\(time)").onReceive(self.timer) { self.time = $0 }
}
.onAppear(perform: {
self.time = Date()
self.timer = Timer.publish(every: 1, on: .main, in: .common)
_ = self.timer.connect()
})
.tabItem {
Text("Page 1")
}

Text("Page 2").tabItem {
Text("Page 2")
}

Text("Page 3").tabItem {
Text("Page 3")
}
}
}
}

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.

How to navigate another view in onReceive timer closure SwiftUI iOS

Here is possible approach (of course assuming that TwitterWebView has NavigationView somewhere in parents)

struct TwitterWebView: View {

@State var timerTime : Float
@State var minute: Float = 0.0
@State private var showLinkTarget = false
let timer = Timer.publish(every: 60.0, on: .main, in: .common).autoconnect()

@State private var shouldNavigate = false

var body: some View {

WebView(url: "https://twitter.com/")
.navigationBarTitle("")
.navigationBarHidden(true)

.background(
NavigationLink(destination: BreakView(),
isActive: $shouldNavigate) { EmptyView() }
)

.onReceive(timer) { _ in
if self.minute == self.timerTime {
print("Timer completed navigate to Break view")
self.timer.upstream.connect().cancel()

self.shouldNavigate = true // << here
} else {
self.minute += 1.0
}
}
}
}

SwiftUI NavigationLink loads destination view immediately, without clicking

The best way I have found to combat this issue is by using a Lazy View.

struct NavigationLazyView<Content: View>: View {
let build: () -> Content
init(_ build: @autoclosure @escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}

Then the NavigationLink would look like this. You would place the View you want to be displayed inside ()

NavigationLink(destination: NavigationLazyView(DetailView(data: DataModel))) { Text("Item") }

SwiftUI Timer restart after canceling

auto-connecting timer in playground

import PlaygroundSupport
import Combine
import Foundation
PlaygroundPage.current.needsIndefiniteExecution = true

var cancellable: AnyCancellable?
// start automatically
cancellable = Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.sink {
print($0)
}

prints

...
2020-02-24 02:22:20 +0000
2020-02-24 02:22:21 +0000
2020-02-24 02:22:22 +0000
2020-02-24 02:22:23 +0000
2020-02-24 02:22:24 +0000
2020-02-24 02:22:25 +0000
2020-02-24 02:22:26 +0000
...

manually start / stop timer

let timerPublisher = Timer.publish(every: 1.0, on: RunLoop.main, in: .default)
cancellable = timerPublisher
.sink {
print($0)
}

/// ...

// start publishing time
let cancellableTimerPublisher = timerPublisher.connect()
// stop publishing time
cancellableTimerPublisher.cancel()

How to trigger action after x seconds in swiftUI

Create a delay, which then sets the @State property hasTimeElapsed to true when the time has elapsed, updating the view body.

With Swift 5.5 and the new concurrency updates (async & await), you can now use task(_:) like the following:

struct ContentView: View {
@State private var hasTimeElapsed = false

var body: some View {
Text(hasTimeElapsed ? "Sorry, too late." : "Please enter above.")
.task(delayText)
}

private func delayText() async {
// Delay of 7.5 seconds (1 second = 1_000_000_000 nanoseconds)
try? await Task.sleep(nanoseconds: 7_500_000_000)
hasTimeElapsed = true
}
}

See more info about Task.sleep(nanoseconds:) in this answer.



Older versions

Xcode 13.0+ now supports concurrency, backwards compatible! However, here is still the 'old' way to do it:

You can use DispatchQueue to delay something. Trigger it with onAppear(perform:) (which happens when the view first appears). You could also hook the delay up to a Button instead if wanted.

Example:

struct ContentView: View {
@State private var hasTimeElapsed = false

var body: some View {
Text(hasTimeElapsed ? "Sorry, too late." : "Please enter above.")
.onAppear(perform: delayText)
}

private func delayText() {
// Delay of 7.5 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 7.5) {
hasTimeElapsed = true
}
}
}

Sound when timer stops swiftUI Apple Watch

When does your timer know when to stop?

You have to define the event when the timer is to be stopped. That's where.invalidate will come handy.

Basic Example:

Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] (timer) in
guard let _weakSelf = self else { timer.invalidate(); return }

_weakSelf.timerVal -= 1

if _weakSelf.timerVal < 0 { //if timer count goes negative then stop timer
timer.invalidate()
} else if _weakSelf.timerVal < 6 {
WKInterfaceDevice.current().play(.success)
}
}

For more control, i.e. If you want to stop the timer from, say, a button tap then we will have to make this timer object global.

Furthermore, if you want to pop the View after the timer is completed/cancelled then we need to make more changes.

This all gets a bit complicated but it's simple to understand.

I would suggest you to break out your timer related logic into an ObservableObject class and use it within your View.

Example:

struct ContentView: View {
@State var isShowingTimer: Bool = false

var body: some View {
NavigationView {
NavigationLink(destination: TimerView(isShowing: $isShowingTimer),
isActive: $isShowingTimer) {
Text("Start Timer")
}
}
}
}
  • isShowingTimer controls the push/pop event for TimerView
    • It is sent as a binding to TimerView so it can be updated from inside TimerView in order to pop.

struct TimerView: View {
//Trigger for popping this view
@Binding var isShowing: Bool

@ObservedObject var timerControl = TimerControl()

var body: some View {
VStack {
Text("\(timerControl.count)")
.onAppear(perform: {
//start timer event
self.timerControl.startTimer(from: 10)
})
.onDisappear(perform: {
//stop timer if user taps on `Back` from the Navigation Bar
self.timerControl.stopTimer()
})
.onReceive(timerControl.$isComplete, //observe timer completion trigger
perform: { (success) in
//hide this view
self.isShowing = !success
})
Text("Cancel")
.onTapGesture(perform: {
//stop timer event
self.timerControl.stopTimer()
})
}
}
}

class TimerControl: ObservableObject {
@Published var count: Int = 0
@Published var isComplete: Bool = false

private var timer: Timer?

init(){}

func startTimer(from count: Int) {
self.count = count

timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] (timer) in
guard let _weakSelf = self else { timer.invalidate(); return }

print(_weakSelf.count)
_weakSelf.count -= 1

if _weakSelf.count <= 0 {
_weakSelf.stopTimer()
} else if _weakSelf.count < 6 {
print(">>make some noise here<<")
}
}
}

func stopTimer() {
guard isComplete == false else { return }
timer?.invalidate()
isComplete = true
}
}

  • ObservableObject class can emit changes
  • @Published variables emit signals of change
  • @ObservedObject listens for changes on the ObservableObject
  • .onReceive handles Publisher events. In this case listens for changes by timerControl.$isComplete


Related Topics



Leave a reply



Submit