Very good answers and I learned a lot from reading them and trying to understand how they work. The key is that the closure parameters are pass by value so as long as the state can flow from call to call (through the params) the claude31's solution is a straightforward way to go.
The sequence argument seemed like a bit of an overkill except for when the number of calls is relatively small. What if I want to countdown 10,000,000 times; that implies a first call with a sequence with 10M elements. The compiler may have some copy-on-write optimization here for the first and successive closures but I don't want to have to reason about it. For my use case I modified it so the state (params size) is O(1):
struct MyView: View {
@State private var countdown: Double = 0
...
func counting(decrement: Double, remaining: Double, limit: Double = 0.0) {
let newRemaining = Double.maximum(remaining - decrement, limit)
self.countdown = newRemaining
if newRemaining == limit { return }
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + decrement) {
counting(decrement: decrement, remaining: newRemaining, limit: limit)
}
}
...
var body: some View {
...
// kickstart it
DispatchQueue.main.asyncAfter(...) {
self.counting(decrement: decrement, remaining: countdown, limit: 0)
}
}
}
I think it's possible that this is not perfect, in that the times the closure executes are not guaranteed so the spacings between the successive calls may not be what I want them to be. I haven't read the DispatQueue's guarantees but it's probably not impossible that the successive closures may execute in a different order (different than the order in which they were added). If this is a problem, then the Timer publisher solution sounds bery much preferable and a better way to go.