SwiftUI .task does not update its references on view update

1 week ago 12
ARTICLE AD BOX

If we are focusing on why the task closure doesn't get the most updated value, you can forget about DoubleGenerator (because randomly initialising class instances in view initialisers is a whole other can of worms) and just consider the simple case of the task capturing a simple Int.

struct TestView: View { let id: Int var body: some View { VStack { Button("Print id") { print(id) } Text("current id is \(id)") }.task { while !Task.isCancelled { print("id is \(id)") try? await Task.sleep(for: .seconds(1)) } } } }

After launching the app it would start printing "id is 0". Would you expect the code to start printing "id is 1" after you press "update id by 1"? I wouldn't.

For it to print "id is 1", the closure must be invoked again, but the task closure is designed to be only invoked once per view lifetime! Docs:

Adds an asynchronous task to perform before this view appears.

Use this modifier to perform an asynchronous task with a lifetime that matches that of the modified view. If the task doesn’t finish before SwiftUI removes the view or the view changes identity, SwiftUI cancels the task.

The task is invoked "before this view appears". When you press "update id by 1", TestView does not "appear" again because its identity did not change. The old closure that captured "0" is still running.

Note that during the view update (i.e. evaluating body), a new task closure that captures "1" is created, but is never invoked (and probably discarded very quickly afterwards). If it were invoked, it would mean the task started again after the view has appeared, which obviously contradicts what the documentation says.

The button's action closure on the other hand, is invoked every time you press the button. It always invokes the latest version of the closure, which captures the latest id.


To illustrate what is happening on SwiftUI's side, let's consider this simplified version of a View, where body is just the two closures we are interested in.

struct Foo { let id: Int // View.body obviously contains more than just these two closures // but these two closures are our focus var body: (task: @MainActor () async -> Void, buttonAction: @MainActor () -> Void) { ( task: { while !Task.isCancelled { print("id is \(id)") try? await Task.sleep(for: .seconds(1)) } }, buttonAction: { print(id) } ) } }

On the SwiftUI side, you can think of it as

// before the view appears, SwiftUI computes the body for the first time let foo = Foo(id: 0) let body = foo.body // the task gets started before the view appears Task { // obviously IRL SwiftUI would keep a reference to this Task and manages it await body.task() } // the view gets rendered after this - not relevant to the question // after a while... // suppose you press "Print id" now. This prints 0 body.buttonAction() // after a while... // now you press "update id by 1", a new Foo gets initialised with the new value let newFoo = Foo(id: 1) let newBody = foo.body // the closure is still created because it's part of body // doesn't do anything with the task closure because view identity did not change. // suppose you press "Print id" now. This prints the updated value of 1 newBody.buttonAction() // The Task printing "id is 0" is still running!
Read Entire Article