0

The following is the essence of the code in question: we have some Int @State that we want to countdown to zero with second intervals but adding closures to the dispatch queue from a function to itself does not seem to work:

func counting(value: inout Int) {
  value -= 1
  if value > 0 {
    // ERROR: Escaping closure captures 'inout' parameter 'value'
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0) {
      counting(value: &value)
    }
  }
}
...
  @State private var countdown: Int
  ...
  // kickstarting the countdown works ok
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0) {
      counting(value: &countdown)
    }
  ...

Is this pattern wrong in principle? If yes, why and what is the simplest correct pattern to use?

jnpdx
  • 45,847
  • 6
  • 64
  • 94
giik
  • 117
  • 6

4 Answers4

1

There was a very elegant answer to a similar question here: DispatchQueue.main.asyncAfter not delaying The recursive pattern would be used as follows:

func counting<T: Sequence>(in sequence: T) where T.Element == Int {
    guard let step = sequence.first(where: { _ in true }) else { return }   
    print("step", step)
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0) {
        counting(in: sequence.dropFirst())       // Recursive call
    }
}

And called without State var:

var countdown: Int = 10

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0) {
    counting(in: 0..<countdown)
}

If you need a State var for the countdown:

@State var countdown: Int = 10
var initialCount = 10

In the View:

func counting<T: Sequence>(in sequence: T) where T.Element == Int {
    guard let step = sequence.first(where: { _ in true }) else { return }      
    print("step", step)
    self.countdown -= 1
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0) {
        counting(in: sequence.dropFirst())
    }
}

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0) {
    counting(in: 0..<initialCount) 
}

Hope that helps.

For the reason why: Swift 3.0 Error: Escaping closures can only capture inout parameters explicitly by value

claude31
  • 874
  • 6
  • 8
  • Very nice and good to know, things get a lot clearer once one understands that the closure params are pass by value. I posted a solution below with a slight variation of the params to avoid passing a sequence with a very large number of elements. I will remember the pattern for future use, thanks! Because my use case is a timer and I may want a higher precision (e.g., 10-100 ms) I think the solution by @Ranoiaetep is actually better suited. – giik Jun 18 '23 at 15:03
0

Is this pattern wrong in principle?

Yes. Despite what it seems like, inout is not a pass-by-reference. Instead it creates a copy, then write it back to the original value when the function exit. So anything that happens in the DispatchQueue won't affect the original value.

You can see more detail about it here: Swift 3.0 Error: Escaping closures can only capture inout parameters explicitly by value


A much simple version of what you were trying to achieve is to use a TimerPublisher, that fires every second. And within the body, you can use .onReceive modifier to subscribe to the publisher:

struct ContentView: View {  
    @State var value: Int = 10
    
    // creates a `TimerPublisher` that fires every second
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        Text(value.formatted())
            .onReceive(timer) {_ in
                // subscribe to `timer`, and perform `counting` every time the `timer` fires
                counting()
            }
    }
    
    func counting() {
        if value == 0 {
            // cancels the original timer once `value` reaches 0
            timer.upstream.connect().cancel()
        } else {
            value -= 1
        }
    }
}
Ranoiaetep
  • 5,872
  • 1
  • 14
  • 39
  • That's very good, thanks for pointing it out (though I did have to read a bit about Publishers, RunLoops and the Timer before I was able to make sense of it all. Are there any guarantees that the notifications from the publisher will not be coalesced, for example, if they come too close together? Also, should one worry about the threading here? There's a UI update thread(s), and the main RunLoop (`on: .main`), does it matter if they differ? That is, updating the state from the callback (presumably in Thread A) and reading it from the UI update thread (presumably in Thread B != A) is *ok*? – giik Jun 18 '23 at 15:12
0

As noted, you cannot capture inout parameters. That doesn't make sense. The way an inout parameter works is that it is copied into the function, and when the function returns, it is copied back. This function tries to keep manipulating it after the function returns; that's invalid.

Since you're using SwiftUI here, the usual approach would be .task:

    .task {
        do {
            while countdown > 0 {
                countdown -= 1
                try await Task.sleep(for: .seconds(1))
            }
        } catch {
            // This might be cancelled
            return
        }
    }

If you really want a function like you've described, that modifies a value recursively (this isn't really very sensible for this example, but it is legal), you'd use a Binding:

@MainActor
func counting(value: Binding<Int>) async {
    value.wrappedValue -= 1
    if value.wrappedValue > 0 {
        do {
            try await Task.sleep(for: .seconds(1))
            await counting(value: value)
        } catch {
            // might cancel
            return
        }
    }
}

You'd kick it off with:

    .task {
        await counting(value: $countdown)
    }
Rob Napier
  • 286,113
  • 34
  • 456
  • 610
0

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.

giik
  • 117
  • 6