Cancel a timer / DispatchQueue.main.asyncAfter in SwiftUI Tap gesture - ios

I am trying to cancel a delayed execution of a function running on the main queue, in a tap gesture, I found a way to create a cancellable DispatchWorkItem, but the issue I have is that it's getting created every time while tapping, and then when I cancel the execution, I actually cancel the new delayed execution and not the first one.
Here is a simpler example with a Timer instead of a DispatchQueue.main.asyncAfter:
.onTapGesture {
isDeleting.toggle()
let timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { timer in
completeTask()
}
if !isDeleting {
timer.invalidate()
}
}
completeTask:
private func completeTask() {
tasksViewModel.deleteTask(task: task) // task is declared above this func at the top level of the struct and so is tasksViewModel, etc.
guard let userID = userViewModel.id?.uuidString else { return }
Task {
//do some async stuff
}
}
As you can see if I click it once the timer fires, but if I click it again, another timer fires and straight away invalidates, but the first timer is still running.
So I have to find a way to create only one instance of that timer.
I tried putting it in the top level of the struct and not inside the var body but the issue now is that I can't use completeTask() because it uses variables that are declared at the same scope.
Also, can't use a lazy initialization because it is an immutable struct.
My goal is to eventually let the user cancel a timed task and reactivate it at will on tapping a button/view. Also, the timed task should use variables that are declared at the top level of the struct.

First of all you need to create a strong reference of timer on local context like so:
var timer: Timer?
and then, set the timer value on onTapGesture closure:
.onTapGesture {
isDeleting.toggle()
self.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { timer in
completeTask()
}
if !isDeleting {
timer.invalidate()
}
}
and after that you can invalidate this Timer whenever you need by accessing the local variable timer like this:
func doSomething() {
timer?.invalidate()
}

that is my solution mb can help you
var timer: Timer?
private func produceWorkItem(withDelay: Double = 3) {
scrollItem?.cancel()
timer?.invalidate()
scrollItem = DispatchWorkItem.init { [weak self] in
self?.timer = Timer.scheduledTimer(withTimeInterval: withDelay, repeats: false) { [weak self] _ in
self?.goToNextPage(animated: true, completion: { [weak self] _ in self?.produceWorkItem() })
guard let currentVC = self?.viewControllers?.first,
let index = self?.pages.firstIndex(of: currentVC) else {
return
}
self?.pageControl.currentPage = index
}
}
scrollItem?.perform()
}
for stop use scrollItem?.cancel()
for start call func

Related

AsyncStream not executing closure

so I'm reading the Modern Concurrency book from raywenderlich.com and I assume the book must be outdated or something, I'm trying to run the closure insde the AsyncStream but it doesn't seem to get there, I'm still pretty new to this Async/Await thing, but when adding some breakpoints I can see my code is not getting there. This is my code and a screenshot with some warnings showing. I am not really familiar with what the warnings mean, just trying to learn all this new stuff, I would truly appreciate some help and is there a way to fix it with Swift 6? Thanks in advance!
Reference to captured var 'countdown' in concurrently-executing code; this is an error in Swift 6
Mutation of captured var 'countdown' in concurrently-executing code; this is an error in Swift 6
func countdown(to message: String) async throws {
guard !message.isEmpty else { return }
var countdown = 3
let counter = AsyncStream<String> { continuation in
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
continuation.yield("\(countdown)...")
countdown -= 1
}
}
for await countDownMessage in counter {
try await say(countDownMessage)
}
}
Timer.scheduleTimer requires that it be scheduled on a run loop. In practical terms, that means we would want to schedule it on the main thread’s run loop. So, you either call scheduleTimer from the main thread, or create a Timer and manually add(_:forMode:) it to RunLoop.main . See the Scheduling Timers in Run Loops section of the Timer documentation.
The easiest way would be to just isolate this function to the main actor. E.g.,
#MainActor
func countdown(to message: String) async throws { … }
There a few other issues here, too:
I would suggest defining the countdown variable within the AsyncStream:
#MainActor
func countdown(to message: String) async throws {
guard !message.isEmpty else { return }
let counter = AsyncStream<String> { continuation in
var countdown = 3
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
continuation.yield("\(countdown)...")
countdown -= 1
}
}
for await countDownMessage in counter {
try await say(countDownMessage)
}
}
The AsyncStream is never finished. You might want to finish it when it hits zero:
#MainActor
func countdown(to message: String) async throws {
guard !message.isEmpty else { return }
let counter = AsyncStream<String> { continuation in
var countdown = 3
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
continuation.yield("\(countdown)...")
// presumably you want this countdown timer to finish when it hits zero
guard countdown > 0 else {
timer.invalidate()
continuation.finish()
return
}
// otherwise, decrement and carry on
countdown -= 1
}
}
for await countDownMessage in counter {
try await say(countDownMessage)
}
}
There should be a continuation.onTermination closure to handle cancelation of the asynchronous sequence.
#MainActor
func countdown(to message: String) async throws {
guard !message.isEmpty else { return }
let counter = AsyncStream<String> { continuation in
var countdown = 3
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
continuation.yield("\(countdown)...")
// presumably you want this countdown timer to finish when it hits zero
guard countdown > 0 else {
timer.invalidate()
continuation.finish()
return
}
// otherwise, decrement and carry on
countdown -= 1
}
continuation.onTermination = { _ in
timer.invalidate()
}
}
for await countDownMessage in counter {
try await say(countDownMessage)
}
}
Going back to the original question (why this is not running), I personally would avoid the use of Timer in conjunction with Swift concurrency at all. A GCD timer would be better, as it doesn’t require a RunLoop. Even better, I would advise Task.sleep. Needless to say, that is designed to work with Swift concurrency, and also is cancelable.
I personally would suggest something like:
func countdown(to message: String) async throws {
guard !message.isEmpty else { return }
let counter = AsyncStream<String> { continuation in
let task = Task {
for countdown in (0...3).reversed() {
try await Task.sleep(for: .seconds(1))
continuation.yield("\(countdown)...")
}
continuation.finish()
}
continuation.onTermination = { _ in
task.cancel()
}
}
for await countDownMessage in counter {
try await say(countDownMessage)
}
}

How can I get multiple Timers to appropriately animate a Shape in SwiftUI?

Apologies in advance for any tacky code (I'm still learning Swift and SwiftUI).
I have a project where I'd like to have multiple timers in an Array count down to zero, one at a time. When the user clicks start, the first timer in the Array counts down and finishes, and a completion handler is called which shifts the array to the left with removeFirst() and starts the next timer (now the first timer in the list) and does this until all timers are done.
I also have a custom Shape called DissolvingCircle, which like Apple's native iOS countdown timer, erases itself as the timer counts down, and stops dissolving when the user clicks pause.
The problem I'm running into is that the animation only works for the first timer in the list. After it dissolves, it does not come back when the second timer starts. Well, not exactly at least: if I click pause while the second timer is running, the appropriate shape is drawn. Then when I click start again, the second timer animation shows up appropriately until that timer ends.
I'm suspecting the issue has to do with the state check I'm making. The animation only starts if timer.status == .running. In that moment when the first timer ends, its status gets set to .stopped, then it falls off during the shift, and then the new timer starts and is set to .running, so my ContentView doesn't appear to see any change in state, even though there is a new timer running. I tried researching some basic principles of Shape animations in SwiftUI and tried re-thinking how my timer's status is being set to get the desired behavior, but I can't come up with a working solution.
How can I best restart the animation for the next timer in my list?
Here is my problematic code below:
MyTimer Class - each individual timer. I set the timer status here, as well as call the completion handler passed as a closure when the timer is finished.
//MyTimer.swift
import SwiftUI
class MyTimer: ObservableObject {
var timeLimit: Int
var timeRemaining: Int
var timer = Timer()
var onTick: (Int) -> ()
var completionHandler: () -> ()
enum TimerStatus {
case stopped
case paused
case running
}
#Published var status: TimerStatus = .stopped
init(duration timeLimit: Int, onTick: #escaping (Int) -> (), completionHandler: #escaping () -> () ) {
self.timeLimit = timeLimit
self.timeRemaining = timeLimit
self.onTick = onTick //will call each time the timer fires
self.completionHandler = completionHandler
}
func start() {
status = .running
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
if (self.timeRemaining > 0) {
self.timeRemaining -= 1
print("Timer status: \(self.status) : \(self.timeRemaining)" )
self.onTick(self.timeRemaining)
if (self.timeRemaining == 0) { //time's up!
self.stop()
}
}
}
}
func stop() {
timer.invalidate()
status = .stopped
completionHandler()
print("Timer status: \(self.status)")
}
func pause() {
timer.invalidate()
status = .paused
print("Timer status: \(self.status)")
}
}
The list of timers is managed by a class I created called MyTimerManager, here:
//MyTimerManager.swift
import SwiftUI
class MyTimerManager: ObservableObject {
var timerList = [MyTimer]()
#Published var timeRemaining: Int = 0
var timeLimit: Int = 0
init() {
//for testing purposes, let's create 3 timers, each with different durations.
timerList.append(MyTimer(duration: 10, onTick: self.updateTime, completionHandler: self.myTimerDidFinish))
timerList.append(MyTimer(duration: 7, onTick: self.updateTime, completionHandler: self.myTimerDidFinish))
timerList.append(MyTimer(duration: 11, onTick: self.updateTime, completionHandler: self.myTimerDidFinish))
self.timeLimit = timerList[0].timeLimit
self.timeRemaining = timerList[0].timeRemaining
}
func updateTime(_ timeRemaining: Int) {
self.timeRemaining = timeRemaining
}
//the completion handler - where the timer gets shifted off and the new timer starts
func myTimerDidFinish() {
timerList.removeFirst()
if timerList.isEmpty {
print("All timers finished")
} else {
self.timeLimit = timerList[0].timeLimit
self.timeRemaining = timerList[0].timeRemaining
timerList[0].start()
}
print("myTimerDidFinish() complete")
}
}
Finally, the ContentView:
import SwiftUI
struct ContentView: View {
#ObservedObject var timerManager: MyTimerManager
//The following var and function take the time remaining, express it as a fraction for the first
//state of the animation, and the second state of the animation will be set to zero.
#State private var animatedTimeRemaining: Double = 0
private func startTimerAnimation() {
let timer = timerManager.timerList.isEmpty ? nil : timerManager.timerList[0]
animatedTimeRemaining = Double(timer!.timeRemaining) / Double(timer!.timeLimit)
withAnimation(.linear(duration: Double(timer!.timeRemaining))) {
animatedTimeRemaining = 0
}
}
var body: some View {
VStack {
let timer = timerManager.timerList.isEmpty ? nil : timerManager.timerList[0]
let displayText = String(timerManager.timeRemaining)
ZStack {
Text(displayText)
.font(.largeTitle)
.foregroundColor(.black)
//This is where the problem is occurring. When the first timer starts, it gets set to
//.running, and so the animation runs approp, however, after the first timer ends, and
//the second timer begins, there appears to be no state change detected and nothing happens.
if timer?.status == .running {
DissolvingCircle(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(animatedTimeRemaining*360-90))
.onAppear {
self.startTimerAnimation()
}
//this code is mostly working approp when I click pause.
} else if timer?.status == .paused || timer?.status == .stopped {
DissolvingCircle(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(Double(timer!.timeRemaining) / Double(timer!.timeLimit)*360-90))
}
}
HStack {
Button(action: {
print("Cancel button clicked")
timerManager.objectWillChange.send()
timerManager.stop()
}) {
Text("Cancel")
}
switch (timer?.status) {
case .stopped, .paused:
Button(action: {
print("Start button clicked")
timerManager.objectWillChange.send()
timer?.start()
}) {
Text("Start")
}
case .running:
Button(action: {
print("Pause button clicked")
timerManager.objectWillChange.send()
timer?.pause()
}){
Text("Pause")
}
case .none:
EmptyView()
}
}
}
}
}
Screenshots:
First timer running, animating correctly.
Second timer running, animation now gone.
I clicked pause on the third timer, ContentView noticed state change. If I click start from here, the animation will work again until the end of the timer.
Please let me know if I can provide any additional code or discussion. I'm glad to also receive recommendations to make other parts of my code more elegant.
Thank you in advance for any suggestions or assistance!
I may have found one appropriate answer, similar to one of the answers in How can I get data from ObservedObject with onReceive in SwiftUI? describing the use of .onReceive(_:perform:) with an ObservableObject.
Instead of presenting the timer's status in a conditional to the ContentView, e.g. if timer?.status == .running and then executing the timer animation function during .onAppear, instead I passed the timer's status to .onReceive like this:
if timer?.status == .paused || timer?.status == .stopped {
DissolvingCircle(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(Double(timer!.timeRemaining) / Double(timer!.timeLimit)*360-90))
} else {
if let t = timer {
DissolvingCircle(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(animatedTimeRemaining*360-90))
.onReceive(t.$status) { _ in
self.startTimerAnimation()
}
}
The view receives the new timer's status, and plays the animation when the new timer starts.

Set a timer with variable intervals in swift

I need to set a specific timer asynchronously after executing an action like this:
calling my function (sending http request)
10 seconds after, sending another request
20 seconds after 2), sending another one
40 seconds after 3), another one
then send every 60 seconds another one
At any moment, I must be able to cancel my timer. Firstable I thought using DispatchQueue, but I see several post saying that it's not possible to cancel it.
Some post suggest to use DispatchWorkItem ( how to stop a dispatchQueue in swift ) but I'm not sur it fit my need (unless adding a sleep(10,20,40,60...) in each loop but will it not impact asynchronous part?).
Another answer from this post suggest to use Timer instead ( scheduledTimerWithTimeInterval ) with repeats:false, and invalidate it after each loop, but I didn't undertand how to do the loop in this case. Actually, here's my code, that just send a request after 10 seconds:
private func start() {
timer?.invalidate()
if(self.PCount > self.Intervals.count){
self.value = self.pollingIntervals.count-1
} else {
self.Value = self.Intervals[self.pCount]
}
print("set timer with \(pollingValue) as interval")
timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(pollingValue), repeats: false, block: { timer in
self.sessionManager.sendHit()
self.pollingCount+=1
})
}
The current goal is to do something like coroutine in Kotlin, like it work with this code :
private val Intervals = longArrayOf(10000,20000,40000,60000)
private var Count = 0
private fun start() {
currentJob = GlobalScope.launch {
while (true) {
delay(Intervals[if (Count > Intervals.size) Intervals.size - 1 else Count]) // 10,20,40 then every 60
session.sendHit()
pollingCount++
}
}
}
I'm not sure what solution is the most appropriate to my project
Here is a basic idea on how to approach the problem
struct RequestMananger {
var timers: [Timer] = []
mutating func startSequence() {
var delay = 10.0
sendRequest()
timers.append(scheduleTimer(delay))
delay += 20
timers.append(scheduleTimer(delay))
delay += 40
timers.append(scheduleTimer(delay))
delay += 60
timers.append(scheduleTimer(delay, repeats: true))
}
private func scheduleTimer(_ delay: TimeInterval, repeats: Bool = false) -> Timer {
return Timer.scheduledTimer(withTimeInterval: delay, repeats: false, block: { timer in
self.sendRequest()
})
}
func sendRequest() {
}
func cancelTimers() {
timers.forEach { $0.invalidate() }
}
}

How Do I Cancel and Restart a Timed Event in Swift?

I have a sliderValueChange function which updates a UILabel's text. I want for it to have a time limit until it clears the label's text, but I also want this "timed clear" action to be cancelled & restarted or delayed whenever the UISlider is moved within the time limit before the "timed clear" action takes place.
So far this is what I have:
let task = DispatchWorkItem {
consoleLabel.text = ""
}
func volumeSliderValueChange(sender: UISlider) {
task.cancel()
let senderValue = String(format: "%.2f", sender.value)
consoleLabel.text = "Volume: \(senderValue)"
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3, execute: task)
}
Obviously, this approach does not work, since cancel() apparently cannot be reversed.. (or at least I don't know how). I also don't know how to start a new task at the end of this function which will be cancelled if the function is recalled..
Am I going about this the wrong way? Is there something I am overlooking to make this work?
Use a timer:
weak var clearTimer: Timer?
And:
override func viewDidLoad() {
super.viewDidLoad()
startClearTimer()
}
func startClearTimer() {
clearTimer = Timer.scheduledTimer(
timeInterval: 3.0,
target: self,
selector: #selector(clearLabel(_:)),
userInfo: nil,
repeats: false)
}
func clearLabel(_ timer: Timer) {
label.text = ""
}
func volumeSliderValueChange(sender: UISlider) {
clearTimer?.invalidate() //Kill the timer
//do whatever you need to do with the slider value
startClearTimer() //Start a new timer
}
The problem is that you are cancelling the wrong thing. You don't want to cancel the task; you want to cancel the countdown which you got going when you said asyncAfter.
So use a DispatchTimer or an NSTimer (now called a Timer in Swift). Those are counters-down that can be cancelled. And then you can start counting again.

Animation callback altering a different variable

I have a button which shows a view and which automatically goes away after specified time interval. Now if the button is pressed again while the view is already visible then it should go away and show a new view and the timer for new view be reset.
On the button press I have following code:
func showToast() {
timer?.invalidate()
timer = nil
removeToast()
var appDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
var toAddView = appDelegate.window!
toastView = UIView(frame: CGRectMake(0, toAddView.frame.height, toAddView.frame.width, 48))
toastView.backgroundColor = UIColor.darkGrayColor()
toAddView.addSubview(toastView)
timer = NSTimer.scheduledTimerWithTimeInterval(2.0, target: self, selector: Selector("removeToast"), userInfo: nil, repeats: false)
UIView.animateWithDuration(0.5, animations: { () -> Void in
self.toastView.frame.origin.y -= 48
})
}
To remove toast i have the following code:
func removeToast() {
if toastView != nil {
UIView.animateWithDuration(0.5,
animations: { () -> Void in
self.toastView.frame.origin.y += 48
},
completion: {(completed: Bool) -> Void in
self.toastView.removeFromSuperview()
self.toastView = nil
})
}
}
Now even though I reset the timer each time by doing timer.invalidate() I get two calls in removeToast() which removes the newly inserted view. Can it be that UIView.animate be causing problems, I'm not getting how to debug the two callbacks for removeToast(). A demo project showing the behavior is here
NOTE: I did find some post saying to use dispatch_after() instead of timer, also as asked by #jervine10, but it does not suffice my scenario. As if I use dispatch_after then it's difficult to invalidate GCD call. Is there something that could be accomplished with NSTimers. I think that NSTimers are meant for this and there's something that I'm doing wrong.
Sorry for not seeing your sample project, and thanks for directing me towards it. I can clearly see where the issue is, and the solution is super simple. Change remove toast to this:
func removeToast() {
guard let toastView = self.toastView else {
return
}
UIView.animateWithDuration(0.1,
animations: { () -> Void in
toastView.frame.origin.y += 48
},
completion: {(completed: Bool) -> Void in
toastView.removeFromSuperview()
if self.toastView == toastView {
self.toastView = nil
}
})
}
Basically, the problem is that you are capturing self in the animation blocks, not toastView. So, once the animation blocks execute asynchronously, they will remove the new toastView set in the previous function.
The solution is simple and also fixes possible race conditions, and that is to capture the toastView in a variable. Finally, we check if the instance variable is equal to the view we are removing, we nullify it.
Tip: Consider using weak reference for toastView
Use dispatch_after to call a method after a set period of time instead of a timer. It would look like this:
let popTime = dispatch_time(DISPATCH_TIME_NOW, Int64(2 * NSEC_PER_SEC))
dispatch_after(popTime, dispatch_get_main_queue()) { () -> Void in
self.removeToast()
}

Resources