I was looking into a cancellable async delay in Swift which is backwards compatible to iOS 15:
Task {
let before = Date.timeIntervalSinceReferenceDate
let seconds: TimeInterval = 100
print("> sleeping for \(seconds) seconds...")
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
let after = Date.timeIntervalSinceReferenceDate
print(String(format: "> slept for %0.3f seconds", after - before))
}
This solution using Task.sleep
works fine, but it seems to be rather inaccurate for longer delays.
Running on iOS 16 simulator yields this:
> sleeping for 20.0 seconds...
> slept for 21.332 seconds
> sleeping for 60.0 seconds...
> slept for 63.995 seconds
> sleeping for 100.0 seconds...
> slept for 106.662 seconds
Is there a better way to achieve this?
To enjoy more accurate sleep
duration, one would specify a leeway/tolerance. In iOS 16+, you have sleep(for:tolerance:clock:)
:
try await Task.sleep(for: .seconds(seconds), tolerance: .zero)
As suggested by Bram, if you need to support iOS 15, you can write your own rendition using, for example, a DispatchSourceTimer
. But, specify a setCancelHandler
to detect/handle the cancellation of the timer:
@available(iOS, deprecated: 16.0, renamed: "Task.sleep(for:tolerance:clock:)")
@available(macOS, deprecated: 13.0, renamed: "Task.sleep(for:tolerance:clock:)")
@available(watchOS, deprecated: 9.0, renamed: "Task.sleep(for:tolerance:clock:)")
@available(tvOS, deprecated: 16.0, renamed: "Task.sleep(for:tolerance:clock:)")
func sleep(for interval: DispatchTimeInterval, leeway: DispatchTimeInterval) async throws {
let queue = DispatchQueue(label: (Bundle.main.bundleIdentifier ?? "sleep") + ".timer", qos: .userInitiated)
let timer = DispatchSource.makeTimerSource(queue: queue)
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
guard !Task.isCancelled else {
continuation.resume(throwing: CancellationError())
return
}
timer.schedule(
deadline: .now() + interval,
repeating: .never,
leeway: leeway
)
timer.setEventHandler {
continuation.resume()
}
timer.setCancelHandler {
continuation.resume(throwing: CancellationError())
}
timer.activate()
}
} onCancel: {
timer.cancel()
}
}
extension DispatchTimeInterval {
static var zero: DispatchTimeInterval { .nanoseconds(0) }
}
And you would call it like so:
try await sleep(for: .seconds(10), leeway: .zero)
This throws CancellationError
if the task is cancelled (like the Task.sleep
API).
Also, if you promote the minimum target OS at some future date to a version that supports the tolerance
variation, the above will produce a warning, suggesting the use of the contemporary API.
As an obligatory aside, the reason that timers and sleep API even have a leeway/tolerance is to afford the coalescing of events to minimize power drain on the device. So one might be best advised to only narrow the tolerance where the app truly requires that, with the recognition of the battery/power implications.