Swiftui - Optional Timer, Reset and Recreate

SwiftUI - Optional Timer, reset and recreate

There is no need to throw away the TimerPublisher itself. Timer.publish creates a Timer.TimerPublisher instance, which like all other publishers, only starts emitting values when you create a subscription to it - and it stops emitting as soon as the subscription is closed.

So instead of recreating the TimerPublisher, you just need to recreate the subscription to it - when the need arises.

So assign the Timer.publish on declaration, but don't autoconnect() it. Whenever you want to start the timer, call connect on it and save the Cancellable in an instance property. Then whenever you want to stop the timer, call cancel on the Cancellable and set it to nil.

You can find below a fully working view with a preview that starts the timer after 5 seconds, updates the view every second and stops streaming after 30 seconds.

This can be improved further by storing the publisher and the subscription on a view model and just injecting that into the view.

struct TimerView: View {
@State private var text: String = "Not started"

private var timerSubscription: Cancellable?

private let timer = Timer.publish(every: 1, on: .main, in: .common)

var body: some View {
Text(text)
.onReceive(timer) {
self.text = "The time is now \($0)"
}
}

mutating func startTimer() {
timerSubscription = timer.connect()
}

mutating func stopTimer() {
timerSubscription?.cancel()
timerSubscription = nil
}
}

struct TimerView_Previews: PreviewProvider {
static var previews: some View {
var timerView = TimerView()
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
timerView.startTimer()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
timerView.stopTimer()
}
return timerView
}
}

With a view model, you don't even need to expose a TimerPublisher (or any Publisher) to the view, but can simply update an @Published property and display that in the body of your view. This enables you to declare timer as autoconnect, which means you don't manually need to call cancel on it, you can simply nil out the subscription reference to stop the timer.

class TimerViewModel: ObservableObject {
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

private var timerSubscription: Cancellable?

@Published var time: Date = Date()

func startTimer() {
timerSubscription = timer.assign(to: \.time, on: self)
}

func stopTimer() {
timerSubscription = nil
}
}

struct TimerView: View {
@ObservedObject var viewModel: TimerViewModel

var body: some View {
Text(viewModel.time.description)
}
}

struct TimerView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = TimerViewModel()
let timerView = TimerView(viewModel: viewModel)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
viewModel.startTimer()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
viewModel.stopTimer()
}
return timerView
}
}

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 Navigation: why is Timer.publish() in View body breaking nav stack

First, if you remove @StateObject from model declaration in ContentView, it will work.

You should not set the whole model as a State for the root view.

If you do, on each change of any published property, your whole hierarchy will be reconstructed. You will agree that if you type changes in the text field, you don't want the complete UI to rebuild at each letter.

Now, about the behaviour you describe, that's weird.
Given what's said above, it looks like when you type, the whole view is reconstructed, as expected since your model is a @State object, but reconstruction is broken by this unmanaged timer.. I have no real clue to explain it, but I have a rule to avoid it ;)

Rule:

You should not make timers in view builders. Remember swiftUI views are builders and not 'views' as we used to represent before. The concrete view object is returned by the 'body' function.

If you put a break on timer creation, you will notice your timer is called as soon as the root view is displayed. ( from NavigationLink(destination: NavLevel2())

That's probably not what you expect.

If you move your timer creation in the body, it will work, because the timer is then created when the view is created.

    var body: some View {
var timer = Timer.publish(every: 2, on: .main, in: .common)
print(Self._printChanges())
return NavigationLink(
destination: NavLevel3()
) { Text("Level 2") }
}

However, it is usually not the right way neither.

You should create the timer:

  • in the .appear handler, keep the reference,
    and cancel the timer in .disappear handler.

  • in a .task handler that is reserved for asynchronous tasks.

I personally only declare wrapped values ( @State, @Binding, .. ) in view builders structs, or very simple primitives variables ( Bool, Int, .. ) that I use as conditions when building the view.

I keep all functional stuffs in the body or in handlers.

Reset main content view - Swift UI

The possible approach is to use global app state

class AppState: ObservableObject {
static let shared = AppState()

@Published var gameID = UUID()
}

and have root content view be dependent on that gameID

@main
struct SomeApp: App {
@StateObject var appState = AppState.shared // << here

var body: some Scene {
WindowGroup {
ContentView().id(appState.gameID) // << here
}
}
}

and now to reset everything to initial state from any place we just set new gameID:

Button("New Game"){
AppState.shared.gameID = UUID()
}

When is a Sheet in SwiftUI re-created?

SwiftUI Views are value types. Modifying them in any way creates a new View, just like modifying an Array creates a new Array. But SwiftUI Views are just data. They don't represent the actual thing displayed on the screen like UIViews. After re-computing the View hierarchy, SwiftUI computes the differences and applies that to its internal state, and renders any changes (which may involve animations).

This means that anything you compute in a body closure must be fast, because you should expect it to be evaluated many times. It should also generally be lazy if possible, to avoid recomputing it if it's not actually necessary. (Constructs like ForEach and List are lazy in this way.)

It's also critical that computing a View does not generate any side-effects, and not rely on global state. You don't control when it will be evaluated, so your Views must rely only on SwiftUI data (such as the state, environment, and values passed directly to the View).

SwiftUI ActionSheet does not dismiss when timer is running

Works fine with Xcode 12 / iOS 14, but try to separate button with sheet into another subview to avoid recreate it on timer counter refresh.

Tested with Xcode 12 / iOS 14

struct ContentView: View {

@ObservedObject var stopWatch = StopWatch()
// @StateObject var stopWatch = StopWatch() // << used for SwiftUI 2.0
@State private var showActionSheet: Bool = false

var body: some View {
VStack {
Text("\(stopWatch.secondsElapsed)")
HStack {
if stopWatch.mode == .stopped {
Button(action: { self.stopWatch.start() }) {
Text("Start")
}
} else if stopWatch.mode == .paused {
Button(action: { self.stopWatch.start() }) {
Text("Resume")
}
} else if stopWatch.mode == .running {
Button(action: { self.stopWatch.pause() }) {
Text("Pause")
}
}
Button(action: { self.stopWatch.stop() }) {
Text("Reset")
}
}
ActionsSubView(showActionSheet: $showActionSheet)
}
}
}

struct ActionsSubView: View {
@Binding var showActionSheet: Bool
var body: some View {
Button(action: { self.showActionSheet = true }) {
Text("Actions")
}
.actionSheet(isPresented: $showActionSheet) {
ActionSheet(title: Text("Actions"), message: nil, buttons: [.default(Text("Action 1")), .cancel()])
}
}
}

SwiftUI Alert does not dismiss when timer is running

Every time timer runs UI will recreate, since "secondsElapsed" is an observable object. SwiftUI will automatically monitor for changes in "secondsElapsed", and re-invoke the body property of your view.
In order to avoid this we need to separate the button and Alert to another view like below.

struct TimerView: View {
@ObservedObject var stopwatch = Stopwatch()
@State var isAlertPresented:Bool = false
var body: some View {
VStack{
Text(String(format: "%.1f", stopwatch.secondsElapsed))
.font(.system(size: 70.0))
.minimumScaleFactor(0.1)
.lineLimit(1)
Button(action:{
stopwatch.actionStartStop()
}){
Text("Toggle Timer")
}
CustomAlertView(isAlertPresented: $isAlertPresented)
}
}
}

struct CustomAlertView: View {
@Binding var isAlertPresented: Bool
var body: some View {
Button(action:{
isAlertPresented.toggle()
}){
Text("Toggle Alert")
}.alert(isPresented: $isAlertPresented){
Alert(title:Text("Error"),message:Text("I am presented"))
}
}
}


Related Topics



Leave a reply



Submit