How to have a view dismiss itself via timer in SwiftUI?

What is the proper way to have a NavigationView destination dismiss itself? I've tried a lot of things and they all behave oddly. For example, the code below (minimal example - will auto-pop after 4 seconds or on a button press) will work correctly once for an "expiring" timer. After that attempting to start the timer on a new view again will cause the view to immediately return to root view. Manually stopping the timer works multiple times.

My suspicion is that there is some strange effect in the way onAppear and onDisappear are called, but I don't know what the right alternatives are. Any ideas? This code does not behave correctly for me with Xcode11 (iOS 13.5) or the Xcode12 Beta (iOS 14).

import SwiftUI

class SimpleTimerManager: ObservableObject {
  @Published var elapsedSeconds: Double = 0.0
  private(set) var timer = Timer()
  
  func start() {
    timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) {_ in
      self.elapsedSeconds += 0.01
    }
  }
  
  func stop() {
    timer.invalidate()
    elapsedSeconds = 0.0
  }
}

struct ContentView: View {
  var body: some View {
    NavigationView {
      NavigationLink(destination: CountDownIntervalView()) {
        Text("Start the timer!")
      }
    }
    .navigationViewStyle(StackNavigationViewStyle())
  }
}

struct CountDownIntervalView: View {
  @ObservedObject var timerManager = SimpleTimerManager()
  @Environment(\.presentationMode) var mode: Binding<PresentationMode>
  var interval: Double { 4.0 - self.timerManager.elapsedSeconds }
    
  var body: some View {
    VStack {
      Text("Time remaining: \(String(format: "%.2f", interval))")
        .onReceive(timerManager.$elapsedSeconds) { _ in
          if self.interval <= 0 {
            self.timerManager.stop()
            self.mode.wrappedValue.dismiss()
          }
      }
      Button(action: {
        self.timerManager.stop()
        self.mode.wrappedValue.dismiss()
      }) {
        Text("Quit early!")
      }
    }
    .navigationBarBackButtonHidden(true)
    .onAppear(perform: {
      self.timerManager.start()
    })
  }
}
1 Like

This question is more about SwiftUI, rather than the language itself. I'd advise that other questions like this are asked over Apple Developer Forums instead.


You're using the same timer across the views, and the code seems to have some timing problem. Namely, the timer is not yet invalidated when a new view appears, so the interval is immediately negative. I think moving self.timerManager.start() to onDisappear should be good enough.

Or you can just do something like this:

struct CountDownIntervalView: View {
    @Environment(\.presentationMode) var mode
    @State var remaining = 4.0
    
    var body: some View {
        VStack {
            Text("Time remaining: \(remaining, specifier: "%.2f")")
            Button("Quit early!") {
                self.mode.wrappedValue.dismiss()
            }
        }
        .navigationBarBackButtonHidden(true)
        .onReceive(Timer.publish(every: 0.01, on: .current, in: .default).autoconnect()) { _ in
            self.remaining -= 0.01
            if self.remaining <= 0 {
                self.mode.wrappedValue.dismiss()
            }
        }
    }
}

Note: you can just use "\(interval, specifier: "%.2f")" on Text.

3 Likes

Hi Lantua. Thanks for the response. Yes, I worry a bit about being somewhat "off-topic" with SwiftUI questions here. I of course did ask this question on the Apple Developer Forums

https://developer.apple.com/forums/thread/655633

and StackOverflow

with very little engagement. The Apple Developer Forum in particular is very slow. (New questions on StackOverflow get attention but once they've been there a while, they sort of fade.)

At any rate, I think your idea that the timer value is still somehow negative at the start was a good insight. The thing that appears to work is explicitly setting the elapsedSeconds to zero when popping. The stop method already does that, but it must be added to an onDisappear. Interestingly, adding an extra stop call in onDisappear doesn't fix the problem. It is instead explicitly setting elapsedSeconds to zero (and it must be in an onDisappear - setting the elapsed time to zero in the dismissal blocks doesn't work either).

All of this smells like multithreading bugs to me. I really worry about the portability and sustainability of this solution.

In this sense, I like your other proposed solution very much! It appears to work and it completely avoids the onAppear and onDisappear paradigm. I had a version of the code a long time ago that used a Timer with autoconnect but something I read somewhere made me worry about not calling invalidate on it. But this is nice and clean.

Thanks again for your response!

1 Like