In the code below I'm using the task modifier task(id:priority:_:)
with an explicitly specified parameter value id
.
According Apple's [documentation] (https://developer.apple.com/documentation/swiftui/view/task(id:priority:_:)):
SwiftUI can automatically cancel the task after the view disappears before the action completes. If the id value changes, SwiftUI cancels and restarts the task.
(emphasises mine).
I'm using the parameter id
to exactly cancel and restart the task when the id value changes. This works as expected and as documented.
However, SwiftUI still cancels the task when the view disappears - even when the id
did not change. This is probably what "can" (well, does it or does it not?!) means in Apple's documentation above. So, in this case it does cancel the task when the view disappears. :p
However, this is not what I want to achieve.
How can I achieve that the task in a task modifier gets only cancelled when the view gets destroyed and gets cancelled and restated when the id changes, but continues when the view just disappears?
I could workaround with an unstructured Task, but is there a better way?
Demo:
The below code shows a TabView with three views each starting a quite long living operation (1000 secs) when this view appears the first time. Imaging this is some network request and you would see an activity indicator. Since this is a TabView, the user can switch tabs. The intended behaviour is, that the operation will not be cancelled when the user switches to another tab. Instead, coming back to the view, it should show the current status (i.e loading) and the operation should just continue.
import SwiftUI
struct ContentView: View {
var body: some View {
TabView {
TabContentView(id: 1)
.tabItem {
Label("One", systemImage: "1.circle")
}
TabContentView(id: 2)
.tabItem {
Label("Two", systemImage: "2.circle")
}
TabContentView(id: 3)
.tabItem {
Label("Three", systemImage: "3.circle")
}
}
}
}
struct TabContentView: View {
let id: Int
var body: some View {
Text("Tab Content \(id)")
.task(id: id) {
do {
print("task (\(id)) started")
try await Task.sleep(for: .seconds(1000))
} catch {
print("task (\(id)) terminated due to error: \(error)")
}
}
}
}
I applied sweepers approach using an Observable and a ViewModifier but there's an issue: (this issue is fixed in sweepers answer).
You can create a class to hold a top-level Task
. If you put an instance of this class in a @StateObject
, then when the deinit
of this class is called, you know that the view is actually completely destroyed. Everything else should be quite simple.
class TaskHolder: ObservableObject {
var task: Task<Void, Never>? {
didSet {
oldValue?.cancel()
}
}
deinit {
task?.cancel()
}
}
struct MyTaskModifier<ID: Equatable>: ViewModifier {
@StateObject var taskHolder = TaskHolder()
@State var appeared = false
let id: ID
let priority: TaskPriority
let task: () async -> Void
func body(content: Content) -> some View {
content
.onAppear {
if !appeared {
appeared = true
taskHolder.task = Task(priority: priority) { @MainActor [task] in
await task()
}
}
}
.onChange(of: id) {
taskHolder.task = Task(priority: priority) { @MainActor [task] in
await task()
}
}
}
}
extension View {
func myTask<T>(
id value: T,
priority: TaskPriority = .userInitiated,
_ action: @escaping () async -> Void
) -> some View where T : Equatable {
modifier(MyTaskModifier(id: value, priority: priority, task: action))
}
}