Update: I have created a formal proposal, which can be seen here:
Hi all,
there is currently a slight imbalance between the sleep APIs for clocks and tasks, and it causes some troubles when dealing with Clock
existentials.
Currently there are two ways to sleep with the Task
type. An instant-based way and a duration-based way:
// Instant-based
try await Task.sleep(until: .now.advanced(by: .seconds(1)), clock: .continuous)
// Duration-based
try await Task.sleep(for: .seconds(1))
The duration-based style has its advantages. It's fewer characters and easier to make use of since you only have to think of a single relative unit rather than transforming an absolute unit.
Unfortunately, Clock
does not have this convenience:
let clock = ContinuousClock()
// Instant-based
try await clock.sleep(until: .now.advanced(by: .seconds(1))
// Duration-based
try await clock.sleep(for: .seconds(1)) // š API does not exist
You are forced to use the longer, instant-based sleep(until:)
method.
It may not seem like a big deal, after all you can always just use the longer API. However, that is not true if you are dealing with Clock
existentials. Because any Clock<Duration>
erases the Instant
associated type, any API dealing with instants is going to be inaccessible to an existential:
let clock: any Clock<Duration> = ContinuousClock()
// Instant-based
try await clock.sleep(until: .now.advanced(by: .seconds(1)) // š
This cannot compile because the compiler doesn't know what to do with the instant associated type.
If we had a sleep(for:)
method that only dealt with durations, and not instants, then we could invoke that API on an existential:
let clock: any Clock<Duration> = ContinuousClock()
// Duration-based
try await clock.sleep(for: .seconds(1)) // ā
if the API existed
And a big reason why you want to be able to invoke sleep
on a clock existential is for controlling the clock so that you are not at the whims of time passing in order to see how your features behave.
For example, suppose you had a view that showed a welcome message after 5 seconds:
struct ContentView: View {
@State var isWelcomeVisible = false
var body: some View {
VStack {
if self.isWelcomeVisible { Text("Welcome!") }
ā¦
}
.task {
do {
try await Task.sleep(until: .now.advanced(by: .seconds(5)))
self.isWelcomeVisible = true
} catch {}
}
}
}
In order to see what this welcome message looks like you will need to literally wait for 5 seconds to pass. This makes it difficult to iterate on the design with a SwiftUI preview.
Alternatively, you can inject an any Clock
into the view so that you can use a continuous/suspending clock when running on a device, but for SwiftUI previews you can use an "immediate" clock that does not do any suspending. However, that is not possible since sleep(until:)
does not work with existentials:
struct ContentView: View {
@State var isWelcomeVisible = false
let clock: any Clock<Duration>
var body: some View {
VStack {
if self.isWelcomeVisible { Text("Welcome!") }
ā¦
}
.task {
do {
try await self.clock.sleep(until: .now.advanced(by: .seconds(5))) š
self.isWelcomeVisible = true
} catch {}
}
}
}
This is also a problem with writing unit tests that involve time-based asynchrony. If you extracted the logic from the above view into an observable object, then you wouldn't be able to test it without forcing your test suite to wait for 5 seconds to pass. But, if you could inject an any Clock<Duration>
into the observable object, then you could substitute an immediate clock for tests, making the test suite run instantly.
I have a branch where this simple method has been added to Clock
:
Is this worthwhile to add to the standard library?