Search code examples
swiftswiftui

How to cancel a task that is running in a view modifier


Consider this code that places fluctuating CatViews in a view's .background modifier:

@State private var catOn = true

...

SomeView()
.background {
    GeometryReader { geo in
        ForEach(0...numCats, id: \.self) { n in
            CatView(color: n%2 == 0 ? color1 : color2, size: 15, isOn: n%2 == 0 ? catOn : !catOn)
            //.frame = ...
        }
        .task {
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1.0))
                catOn.toggle()
            }
        }
    }
    
}

The problem is that I think this code is causing a crash sometimes when the view is replaced by another view, perhaps because the task fires during the transition (I don't get the crash when I comment out the .task). How can I cancel this task when a button is pushed, before I trigger the property that transitions this view away? Also, is there a better way to set this task up? As you can see, I need the CatViews to swap colors every second.


Solution

  • First, you should catch cancellation errors thrown by sleep so that catOn is not toggled when the tasks is cancelled during the sleep:

    while !Task.isCancelled {
        do {
            try await Task.sleep(for: .seconds(1.0))
            catOn.toggle()
        } catch is CancellationError {
            return // the task is cancelled during the sleep
        } catch {
            fatalError("Something unexpected happened")
        }
    }
    

    The task will still only be cancelled when the view disappears. To cancel the task immediately on demand, you can use the task(id:) overload. When this id changes, the currently running task is cancelled, and a new task is started. You can return immediately when the new id is some sentinel value. For example:

    @State var cancelled = false
    @State var catOn = false
    
    var body: some View {
        Toggle("Cat On", isOn: $catOn)
        
        Button("Cancel Task") {
            cancelled = true
        }
        .task(id: cancelled) {
            if cancelled { return }
    
            while !Task.isCancelled {
                do {
                    try await Task.sleep(for: .seconds(1.0))
                    catOn.toggle()
                } catch is CancellationError {
                    return
                } catch {
                    fatalError("Something unexpected happened")
                }
            }
        }
    }