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
Swift Continuous Rotation Animation Not So Continuous
Fill Uiview with Color Based on Percentage
Adding Uigesturerecognizer to Subview Programmatically in Swift
Saving Already Created Live Photos
Why the Autolayout Is Not Updated If I Use Ibdesignable File When I Run the App
How to Get Date from String in Swift 3
How to Insert Items at 0 Index to the Realm Container
How to Add File Picker to the App on iOS 14+ and Lower
Turning on Thread Sanitizer Results in Signal Sigabrt
How to Use Nsuserdefaults to Store an Array of Custom Classes in Swift
Include Pods in Main Target and Not in Watchkit Extension
Custom Sequence for Swift Dictionary
Swift: Have Searchbar Search Through Both Sections and Not Combine Them
How to Get the Correct Current Time in iOS
iOS Keyboard Active But Invisible When Uisearchbar Is Tapped
How to Make a Uiimage Scrollable Inside a Uiscrollview
How to Retrieve Image Stored in Firebase to Show It in View Image View