[28/10/2019: I have recreated the same test project with UIKit... unsurprisingly, the timer does not make any of the view controller views reload!!! So this looks like a SwiftUI bug. UIKIt version: https://github.com/DominikButz/DispatchSourceTimerUIKit.git]
[19/10/2019: Please check out https://github.com/DominikButz/DispatchTimerSwiftUIReloadBug.git for better understanding of the issue. When you launch this in the simulator, just tap on "Launch timer". Check the debug console, you will see that all views are reloaded every second.]
Original post:
I'm creating an iOS app in SwiftUI for which I need to use a timer. I'm posting the class below.
The timer works fine (except for not continuing when the app is in the background, but that is another story...). As you can see in the resumeTimer function, the timer is set to repeat every second.
There is a view in which a Text label gets updated every time the timer fires. This works fine. However, the problem is that other views that are loaded after the timer starts all get reloaded every time the timer fires, that means every second. e.g. an unrelated list (where the timer text label is not placed) gets reloaded every second, which makes it unusable.
I know this because when I pause or cancel the timer through the user interface, the debugger shows that the views don't get reloaded every second anymore. I placed a breakpoint (with the option "continue after evaluation") in the body of each of those views and the debugger shows that the views gets reloaded every time the timer fires.
The timer class below is created in the SceneDelegate and then passed to the Content view as environment object. I had it as ObservedObject before only in the view where the timer Text label is placed, but the result is the same. See below a view that gets reloaded when the timer fires... (gif screenshot).
Could this be a bug in SwiftUI? or might there be an error in my timer class? thanks for any hints in the right direction
Here is the content view
struct ContentView: View {
#EnvironmentObject var workoutModel: WorkoutModel
#EnvironmentObject var workoutWatch: WorkoutStopWatchTimer
#State var selectedMenuIndex = UserDefaults.standard.integer(forKey: UserDefaultKeys.selectedMenuIndex)
#State private var workoutViewPresenting = false
func setMenuIndex() {
UserDefaults.standard.set(selectedMenuIndex, forKey: UserDefaultKeys.selectedMenuIndex)
}
var body: some View {
TabView(selection:$selectedMenuIndex) {
WorkoutSelectionView(workoutViewPresenting: self.$workoutViewPresenting).onAppear(perform: self.setMenuIndex)
.tabItem {
Image(systemName: "1.square.fill")
Text("Start Workout")
}.tag(0)
Text("History").onAppear(perform: self.setMenuIndex) // I set a breakpoint here (with continue after evaluation checked)
.tabItem {
Image(systemName: "2.square.fill")
Text("History")
}.tag(1)
ExerciseListView().onAppear(perform: self.setMenuIndex) // I set a breakpoint here (with continue after evaluation checked)
.tabItem {
Image(systemName: "3.square.fill")
Text("Exercises")
}.tag(2)
Text("Profile").onAppear(perform: self.setMenuIndex)
.tabItem {
Image(systemName: "person.fill")
Text("Profile")
}.tag(3)
}.sheet(isPresented: self.$workoutViewPresenting) {
// the timer is passed to this view in which a Text label is updated every second to show the time
WorkoutView().environmentObject(self.workoutModel).environmentObject(self.workoutWatch)
}
}
}
debugger console: see the comments I put in the content view above to see where I placed the breakpoints. I did not open and close those views that many times, they are reloaded automatically every second when the timer is running.
...
3 loading History
3 loading Exercise List View
4 loading History
4 loading Exercise List View
5 loading History
5 loading Exercise List View
6 loading History
6 loading Exercise List View
7 loading History
7 loading Exercise List View
...
class WorkoutStopWatchTimer: ObservableObject {
private var sourceTimer: DispatchSourceTimer?
private let queue = DispatchQueue.init(label: "stopwatch.timer", qos: .background, attributes: [], autoreleaseFrequency: .never, target: nil)
private var counter: Int = 0 // seconds
var endDate: Date?
var duration: TimeInterval?
#Published var timeDisplayString = "0:00"
var paused = false
var isActive: Bool {
return self.sourceTimer != nil
}
func start() {
self.paused = false
guard let _ = self.sourceTimer else {
self.startTimer()
return
}
self.resumeTimer()
}
func finish() {
guard self.sourceTimer != nil else {return}
self.endDate = Date()
self.duration = TimeInterval(exactly: Double(self.counter))
self.sourceTimer?.setEventHandler {}
self.sourceTimer?.cancel()
if self.paused == true {
self.sourceTimer?.resume()
}
self.sourceTimer = nil
self.reset()
}
func pause() {
self.paused = true
self.sourceTimer?.suspend()
}
private func reset() {
self.timeDisplayString = "0:00"
self.counter = 0
}
private func startTimer() {
self.sourceTimer = DispatchSource.makeTimerSource(flags: DispatchSource.TimerFlags.strict,
queue: self.queue)
self.resumeTimer()
}
private func resumeTimer() {
self.sourceTimer?.setEventHandler { [weak self] in
// self.eventHandler = {
self?.updateTimer()
// }
}
self.sourceTimer?.schedule(deadline: .now(),
repeating: 1)
self.sourceTimer?.resume()
}
private func updateTimer() {
self.counter += 1
DispatchQueue.main.async {
self.timeDisplayString = WorkoutStopWatchTimer.convertCountToTimeString(counter: self.counter)
}
}
}
Related
I am creating an app that has some code that takes a bit to execute. I want it to hang the app as I don't want the user to make any changes when it's executing. It's a Multiplatform app so when executing in macOS it automatically changes the mouse to a rolling ball image so that works-ish. In iOS there's no feedback at all. I want to create a popup (thinking alert but not too fussy) that displays an updating message showing the user what's happening and making it obvious they have to wait.
Right now my View calls a class that executes the code so I wanted to somehow pass a variable that gets updated in the class but is visible in the View in real-time. Ideally I would want to be able to use this and call different methods each time from other Views but still use a popup with messages updating the user while the code executes.
To simplify this I created a mini project but I can't get it to work on either the macOS OR iOS as the View (app) isn't updated until after the code finishes executing (also have print statements to know what's happening). I've been trying #StateObject, #Published, ObservableObject, etc to no avail.
Code: ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
#StateObject var progress = myProgress()
#State var showProgress:Bool = false
var body: some View {
NavigationView {
VStack {
Button(action: {
print("Pressed Button 1")
showProgress = true
}, label: {
Text("Execute Option")
})
.sheet(isPresented: $showProgress, content: {
Text(progress.message)
.onAppear() {
Task {
let longMethod = longMethodsCalled(currProgress: progress)
print("About to call method - \(progress.message)")
let error = longMethod.exampleOne(title: "My Passed In Title")
// Here I can use the returned value if it's an object or now if it passed if it's String?
print("Error: \(error ?? "No error")")
print("after printing error - \(progress.message)")
}
// If this is commented out it just shows the last message after code finishes
showProgress = false
}
})
}
}
}
}
Other file: longMethodsCalled.swift
import Foundation
import SwiftUI
class myProgress: ObservableObject {
#Published var message = "progressing..."
func changeMessage(newMessage:String) {
message = newMessage
print("Message changing. Now: \(message)")
self.objectWillChange.send()
}
}
class longMethodsCalled {
#State var progress: myProgress
init(currProgress:myProgress) {
progress = currProgress
}
public func exampleOne(title:String) -> String? {
print("in example one - \(title)")
progress.changeMessage(newMessage: "Starting example one")
sleep(1)
print("after first sleep")
progress.changeMessage(newMessage: "Middle of example one")
sleep(1)
progress.changeMessage(newMessage: "About to return - example one")
return "result of example one"
}
}
I guess I'm wondering if this is even possible? And if so how can I go about it. I can't tell if I'm close or completely out to lunch. I would REALLY love a way to update my users when my code executes.
Thanks for any and all help.
Here is an example using binding to do all view update in another struct. It is using async and await. For the sleep(), it use Task.sleep which does not lock queues.
struct LongMethodCallMessage: View {
#State var showProgress:Bool = false
#State var progressViewMessage: String = "will do something"
var body: some View {
NavigationView {
VStack {
Button(action: {
print("Pressed Button 1")
progressViewMessage = "Pressed Button 1"
showProgress = true
}, label: {
// text will be return value
// so one can see that it ran
Text(progressViewMessage)
})
.sheet(isPresented: $showProgress, content: {
// create the vue that will display the progress
TheLongTaskView(progressViewMessage: $progressViewMessage, showProgress: $showProgress)
})
}
}
}
}
struct TheLongTaskView: View, LongMethodsCalledMessage {
#Binding var progressViewMessage: String
#Binding var showProgress: Bool
var body: some View {
Text(progressViewMessage)
.onAppear() {
// create the task setting this as delegate
// to receive message update
Task {
let longMethod = LongMethodsCalled(delegate: self)
print("About to call method - \(progressViewMessage)")
let error = await longMethod.exampleOne(title: "My Passed In Title")
// Here I can use the returned value if it's an object or now if it passed if it's String?
print("Error: \(error ?? "No error")")
print("after printing error - \(progressViewMessage)")
// save the error message and close view
progressViewMessage = error!
showProgress = false
}
}
}
// updating the text
func changeMessage(newMessage:String) {
print("changeMessage: \(newMessage)")
progressViewMessage = newMessage
}
}
// a protocol to update the text in the long running task
protocol LongMethodsCalledMessage {
func changeMessage(newMessage:String)
}
class LongMethodsCalled {
var delegate: LongMethodsCalledMessage
init(delegate: LongMethodsCalledMessage) {
self.delegate = delegate
}
// make the method async
public func exampleOne(title:String) async -> String? {
print("in example one - \(title)")
self.delegate.changeMessage(newMessage: "Starting example one")
// this wait enable the text to update
// the sleep() may lock and prevent main queue to run
try! await Task.sleep(nanoseconds: 2_000_000_000)
print("after first sleep")
self.delegate.changeMessage(newMessage: "Middle of example one")
try! await Task.sleep(nanoseconds: 2_000_000_000)
print("after second sleep")
self.delegate.changeMessage(newMessage: "About to return - example one")
return "result of example one"
}
}
I feel like I must be doing something stupid. All I'm trying to do if pull from UserDefaults to set a Picker value, then save to UserDefaults when the Picker changes. The issue I'm running into comes when I try and make the code work for iOS14 and 15.
struct SettingsView: View {
#State var rangeSelection: Int = UserDefaults.standard.integer(forKey: "vocalRange")
#State var rangeOptions = ["Soprano", "Mezzo-Soprano/Alto", "Extended Range"]
var body: some View {
VStack{
Form {
Section {
Picker(selection: $rangeSelection, label: Text("Range")) {
ForEach(0 ..< rangeOptions.count, id: \.self) { range in
Text(rangeOptions[range])
.tag(range)
}
}
.onChange(of: rangeSelection, perform: { value in
UserDefaults.standard.setValue(rangeSelection, forKey: "vocalRange")
})
}
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
}
}
This seems to work fine, and the 'onChange' fires great. Only weird thing is, if you leave the page and immediately go back in, the 'rangeSelection' variable resets to what it was originally. Once you restart the app however, the change is reflected correctly.
As an alternative, I tried the following
#State var rangeSelection: Int = 0
...
.onAppear(perform:{
rangeSelection = UserDefaults.standard.integer(forKey: "vocalRange")
})
This creates another fun issue. In iOS15 it works fine, but in iOS14, once you've changed the picker, the OnAppear fires before the OnChange, and reverts the Picker back to its previous value. This only happens because I'm using the picker in a Form/List, so it changes pages when you select the Picker.
I feel like there is an obvious solution I should be using.
You can set delay in .OnAppear method to work it
.onAppear(perform:{
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
rangeSelection = UserDefaults.standard.integer(forKey: "vocalRange")
}
})
Thanks to #loremipsum I used AppStorage. I declared the variable like this, and no 'onChange' was needed!
#AppStorage("vocalRange") var rangeSelection: Int = UserDefaults.standard.integer(forKey: "vocalRange")
Scenario
A simple SwiftUI App that consists of a TabView with two tabs. The App struct has a #StateObject property, which is being repeatedly and very quickly (30 times per second) updated by simulateFastStateUpdate.
In this example, simulateFastStateUpdate is not doing any useful work, but it closely resembles a real function that quickly updates the app's state. The function does some work on a background queue for a short interval of time and then schedules a state update on the main queue. For example, when using the camera API, the app might update the preview image as frequently as 30 times per second.
Question
When the app is running, the TabView does not respond to taps. It's permanently stuck on the first tab. Removing liveController.message = "Nice" line fixes the issue.
Why is TabView stuck?
Why is updating #StateObject causing this issue?
How to adapt this simple example, so that the TabView is not stuck?
import SwiftUI
class LiveController: ObservableObject {
#Published var message = "Hello"
}
#main
struct LiveApp: App {
#StateObject var liveController = LiveController()
var body: some Scene {
WindowGroup {
TabView() {
Text(liveController.message)
.tabItem {
Image(systemName: "1.circle")
}
Text("Tab 2")
.tabItem {
Image(systemName: "2.circle")
}
}
.onAppear {
DispatchQueue.global(qos: .userInitiated).async {
simulateFastStateUpdate()
}
}
}
}
func simulateFastStateUpdate() {
DispatchQueue.main.async {
liveController.message = "Nice"
}
// waits 33 ms ~ 30 updates per second
usleep(33 * 1000)
DispatchQueue.global(qos: .userInitiated).async {
simulateFastStateUpdate()
}
}
}
You are blocking the main thread with these constant updates and the app is busy processing your UI updates and can't handle touch inputs (also received on the main thread).
Whatever creates this rapid event stream needs to be throttled. You can use Combine's throttle or debounce functionality to reduce the frequency of your UI updates.
Look at this sample, I added the class UpdateEmittingComponent producing updates with a Timer. This could be your background component updating rapidly.
In your LiveController I'm observing the result with Combine. There I added a throttle into the pipeline which will cause the message publisher to fiere once per second by dropping all in-between values.
Removing the throttle will end up in an unresponsive TabView.
import SwiftUI
import Combine
/// class simulating a component emitting constant events
class UpdateEmittingComponent: ObservableObject {
#Published var result: String = ""
private var cancellable: AnyCancellable?
init() {
cancellable = Timer
.publish(every: 0.00001, on: .main, in: .default)
.autoconnect()
.sink {
[weak self] _ in
self?.result = "\(Date().timeIntervalSince1970)"
}
}
}
class LiveController: ObservableObject {
#Published var message = "Hello"
#ObservedObject var updateEmitter = UpdateEmittingComponent()
private var cancellable: AnyCancellable?
init() {
updateEmitter
.$result
.throttle(for: .seconds(1),
scheduler: RunLoop.main,
latest: true
)
.assign(to: &$message)
}
}
#main
struct LiveApp: App {
#StateObject var liveController = LiveController()
var body: some Scene {
WindowGroup {
TabView() {
Text(liveController.message)
.tabItem {
Image(systemName: "1.circle")
}
Text("Tab 2")
.tabItem {
Image(systemName: "2.circle")
}
}
}
}
}
Normally, I would use an optional variable to hold my Timer reference, as it's nice to be able to invalidate and set it to nil before recreating.
I'm trying to use SwiftUI and want to make sure I'm correctly doing so...
I declare as:
#State var timer:Publishers.Autoconnect<Timer.TimerPublisher>? = nil
Later I:
self.timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
To drive a UI text control I use:
.onReceive(timer) { time in
print("The time is now \(time)")
}
What is the right way with this Combine typed Timer to invalidate and recreate?
I've read one should call:
self.timer.upstream.connect().cancel()
However, do I also need to invalidate or simply then nil out?
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
}
}
I have a simple app that is intended to work like this:
App loads with a default image
Once a tap is received a random image is loaded from list
If no tap is received for 5 seconds we reset back to default image
I'm attempting to accomplish this using DispatchQueue and DispatchWorkItem. I have managed to get the above functionality working - except when a user is tapping multiple times.
I'm assuming this is because we are initiating the "resetToOff" DispatchWorkItem each time we tap without resetting the 5 seconds.
How do I reset the image back to the default image if there is no touch received for 5 seconds while resetting the DispatchQueue?
Here is what I have so far:
import SwiftUI
struct PlayView : View {
#ObservedObject var viewRouter: ViewRouter
#State var imageName : String = "smiley"
var body: some View {
ZStack {
Color.black
.edgesIgnoringSafeArea(.all)
Image(imageName)
}
.gesture(
TapGesture()
.onEnded {
let resetToOff = DispatchWorkItem {
self.imageName = "smiley"
}
self.changeImage()
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5), execute: resetToOff)
}
)
// Activate the options menu
.onLongPressGesture(minimumDuration: 3) {
self.viewRouter.currentPage = "menuView"
}
}
func changeImage() {
let tempImageName : String = self.imageName
let list : Array = [
"smileyPink",
"smileyGreen",
"smileyRed",
"smileyBlue",
"smileyYellow"
]
self.imageName = list.randomElement() ?? ""
// Ensure that new image selection is not the same as previous image
while tempImageName == self.imageName {
self.imageName = list.randomElement() ?? ""
}
}
}
struct PlayView_Previews : PreviewProvider {
static var previews: some View {
PlayView(viewRouter: ViewRouter())
}
}
Any help with this would be much appreciated.
I think it's really easy to modify your code so that resetToOff only does something if 5 or more seconds passed from last tap:
var lastTapped: DispatchTime
...
.gesture(
TapGesture()
.onEnded {
lastTapped = DispatchTime.now() // remember the time of last tap
and then:
let resetToOff = DispatchWorkItem {
if self.lastTapped + .seconds(5) <= DispatchTime.now() { // 5 sec passed from last tap
self.imageName = "smiley"
} // otherwise do nothing
}
Since both setting and modifying lastTapped happens on main thread, it's thread safe. Of course it means you potentially adding "no work needed" item to main queue, but it's likely a very low impact.
It would be simpler to use a Timer, because that is something that is easy to cancel (invalidate) and start the timer again (Timer.scheduledTimer) if the user taps before the Timer fires at the end of 5 seconds.
For example, that is how my LinkSame app works. There is a 10-second "move" timer. If the user doesn't make a valid move within 10 seconds of the previous move, the user loses 10 points and the timer starts over. If the user does make a valid move within 10 seconds of the previous move, the user gets a score based on where we are in the timer and the timer starts over. That is all accomplished with a Timer.