How to cancel a background task in Swift

The async/await syntax, introduced in Swift 5.5, allows a readable way to write asynchronous code. Asynchronous programming can improve performance of an app, but it is important to cancel unneeded tasks to ensure unwanted background tasks do not interfere with the app. This article demonstrates how to cancel a task explicitly and shows how the child tasks are automatically cancelled.

The code builds on the AsyncLetApp initiated in Use async let to run background tasks in parallel in Swift.



Why cancel a background tasks

Interaction with a view may trigger a background task to run and further interaction may make the initial request obsolete and trigger a subsequent background task to run. Aside from a waste of resources, not cancelling the initial task may result in intermittent and unexpected behaviour in your app.

A cancel button is added to the View with the action to call cancel in the ViewModel. Some logging is added to the ViewModel to print out when the file download is incremented and when the file isDownloading property is set to false. It can be seen that, after the download is cancelled, the task continues and eventually sets the isDownloading property to false. This can cause a problem in the UI if a download is cancelled and a subsequent download is started quickly. The isDownloading property from the first task is set to false with the effect of stopping the second download.


View

 1struct NaiveCancelView: View {
 2    @ObservedObject private var dataFiles: DataFileViewModel5
 3    @State var fileCount = 0
 4    
 5    init() {
 6        dataFiles = DataFileViewModel5()
 7    }
 8    
 9    var body: some View {
10        ScrollView {
11            VStack {
12                TitleView(title: ["Naive Cancel"])
13                
14                HStack {
15                    Button("Download All") {
16                        Task {
17                            let num = await dataFiles.downloadFile()
18                            fileCount += num
19                        }
20                    }
21                    .buttonStyle(BlueButtonStyle())
22                .disabled(dataFiles.file.isDownloading)
23                    Button("Cancel") {
24                        dataFiles.cancel()
25                    }
26                    .buttonStyle(BlueButtonStyle())
27                    .disabled(!dataFiles.file.isDownloading)
28                }
29                
30                Text("Files Downloaded: \(fileCount)")
31                
32                HStack(spacing: 10) {
33                    Text("File 1:")
34                    ProgressView(value: dataFiles.file.progress)
35                        .frame(width: 180)
36                    Text("\((dataFiles.file.progress * 100), specifier: "%0.0F")%")
37                    
38                    ZStack {
39                        Color.clear
40                            .frame(width: 30, height: 30)
41                        if dataFiles.file.isDownloading {
42                            ProgressView()
43                                .progressViewStyle(CircularProgressViewStyle(tint: .blue))
44                        }
45                    }
46                }
47                .padding()
48
49                .onDisappear {
50                    dataFiles.cancel()
51                }
52                
53                Spacer().frame(height: 200)
54                
55                Button("Reset") {
56                    dataFiles.reset()
57                }
58                .buttonStyle(BlueButtonStyle())
59                
60                Spacer()
61            }
62            .padding()
63        }
64    }
65}

ViewModel

 1class DataFileViewModel5: ObservableObject {
 2    @Published private(set) var file: DataFile
 3    
 4    init() {
 5        self.file = DataFile(id: 1, fileSize: 10)
 6    }
 7    
 8    func downloadFile() async -> Int {
 9        await MainActor.run {
10            file.isDownloading = true
11        }
12        
13        for _ in 0..<file.fileSize {
14            await MainActor.run {
15                guard file.isDownloading else { return }
16                file.increment()
17                print("  --   Downloading file - \(file.progress * 100) %")
18            }
19            usleep(300000)
20        }
21        
22        await MainActor.run {
23            file.isDownloading = false
24            print(" ***** File : \(file.id) setting isDownloading to FALSE")
25        }
26        
27        return 1
28    }
29    
30    func cancel() {
31        file.isDownloading = false
32        self.file = DataFile(id: 1, fileSize: 10)
33    }
34        
35    func reset() {
36        self.file = DataFile(id: 1, fileSize: 10)
37    }
38}

The second download task is stopped by the completion of the first background task
The second download task is stopped by the completion of the first background task



Use Cancel Flag

There are a number of approaches to cancel work in a background task. One mechanism is to add a status flag to the object with the asynchronous task and to monitor this flag while the task is running. There are no changes to the View required, the cancel button still calls the Cancel function in the ViewModel.

The changes to the ViewModel include the addition of a cancelFlag boolean property, which has to be marked with MainActor as it needs to be updated on the main UI thread. The loop that is simulating a file download is updated from a for loop to a while loop dependent on two conditions:

  • the cancel flag is false
  • the file is downloading

This fixes the problem, but it seems excessive to have an extra flag for cancelling a download.


View

 1struct CancelFlagView: View {
 2    @ObservedObject private var dataFiles: DataFileViewModel6
 3    @State var fileCount = 0
 4
 5    init() {
 6        dataFiles = DataFileViewModel6()
 7    }
 8    
 9    var body: some View {
10        ScrollView {
11            VStack {
12                TitleView(title: ["Use Cancel Flag"])
13                
14                HStack {
15                    Button("Download All") {
16                        Task {
17                            let num = await dataFiles.downloadFile()
18                            fileCount += num
19                        }
20                    }
21                    .buttonStyle(BlueButtonStyle())
22                .disabled(dataFiles.file.isDownloading)
23                    Button("Cancel") {
24                        dataFiles.cancel()
25                    }
26                    .buttonStyle(BlueButtonStyle())
27                    .disabled(!dataFiles.file.isDownloading)
28                }
29                
30                Text("Files Downloaded: \(fileCount)")
31                
32                HStack(spacing: 10) {
33                    Text("File 1:")
34                    ProgressView(value: dataFiles.file.progress)
35                        .frame(width: 180)
36                    Text("\((dataFiles.file.progress * 100), specifier: "%0.0F")%")
37                    
38                    ZStack {
39                        Color.clear
40                            .frame(width: 30, height: 30)
41                        if dataFiles.file.isDownloading {
42                            ProgressView()
43                                .progressViewStyle(CircularProgressViewStyle(tint: .blue))
44                        }
45                    }
46                }
47                .padding()
48                
49                .onDisappear {
50                    dataFiles.cancel()
51                }
52
53                Spacer().frame(height: 200)
54                
55                Button("Reset") {
56                    dataFiles.reset()
57                }
58                .buttonStyle(BlueButtonStyle())
59                
60                Spacer()
61            }
62            .padding()
63        }
64    }
65}

ViewModel

 1class DataFileViewModel6: ObservableObject {
 2    @Published private(set) var file: DataFile
 3    
 4    @MainActor var cancelFlag = false
 5    
 6    init() {
 7        self.file = DataFile(id: 1, fileSize: 10)
 8    }
 9    
10    func downloadFile() async -> Int {
11        await MainActor.run {
12            file.isDownloading = true
13        }
14        
15        while await !cancelFlag, file.isDownloading {
16            await MainActor.run {
17                print("  --   Downloading file - \(file.progress * 100) %")
18                file.increment()
19            }
20            usleep(300000)
21        }
22        
23        await MainActor.run {
24            file.isDownloading = false
25            print(" ***** File : \(file.id) setting isDownloading to FALSE")
26        }
27        
28        return 1
29    }
30  
31    @MainActor
32    func cancel() {
33        self.cancelFlag = true
34        print(" ***** File : \(file.id) setting cancelFlag to TRUE")
35        self.reset()
36    }
37        
38    @MainActor
39    func reset() {
40        self.file = DataFile(id: 1, fileSize: 10)
41        self.cancelFlag = false
42    }
43}

Use of cancel flag in ViewModel to end the background loop
Use of cancel flag in ViewModel to end the background loop



Cancel the task instance - Task.checkCancellation()

A more elegant solution is to create a State property for the Task and assign the task to this property in the view for the Download button action. The Cancel button can then cancel this task. That sounds simple, right! Well, there's a little more to do because, as the documentation says:

Tasks include a shared mechanism for indicating cancellation, but not a shared implementation for how to handle cancellation.

This is because how a task is cancelled will vary depending on what the task is doing.

In this example, the downloadFile function in the ViewModel is changed to use checkCancellation within the loop of the download. This checks for cancellation and will throw an error if the task has been cancelled. When this error is thrown, the isDownloading flag can be set to false and optionally, the ViewModel can be reset.

In this case, the Cancel Flag and all the associated code can be removed completely from the ViewModel.


View

 1struct CancelTaskView: View {
 2    @ObservedObject private var dataFiles: DataFileViewModel7
 3    @State var fileCount = 0
 4    
 5    @State var fileDownloadTask: Task<Void, Error>?
 6    
 7    init() {
 8        dataFiles = DataFileViewModel7()
 9    }
10    
11    var body: some View {
12        ScrollView {
13            VStack {
14                TitleView(title: ["Cancel Task", "", "<checkCancellation()>"])
15                
16                HStack {
17                    Button("Download All") {
18                        fileDownloadTask = Task {
19                            let num = await dataFiles.downloadFile()
20                            fileCount += num
21                        }
22                    }
23                    .buttonStyle(BlueButtonStyle())
24                    .disabled(dataFiles.file.isDownloading)
25                    Button("Cancel") {
26                        fileDownloadTask?.cancel()
27                    }
28                    .buttonStyle(BlueButtonStyle())
29                    .disabled(!dataFiles.file.isDownloading)
30                }
31                
32                Text("Files Downloaded: \(fileCount)")
33                
34                HStack(spacing: 10) {
35                    Text("File 1:")
36                    ProgressView(value: dataFiles.file.progress)
37                        .frame(width: 180)
38                    Text("\((dataFiles.file.progress * 100), specifier: "%0.0F")%")
39                    
40                    ZStack {
41                        Color.clear
42                            .frame(width: 30, height: 30)
43                        if dataFiles.file.isDownloading {
44                            ProgressView()
45                                .progressViewStyle(CircularProgressViewStyle(tint: .blue))
46                        }
47                    }
48                }
49                .padding()
50                
51                .onDisappear {
52                    fileDownloadTask?.cancel()
53                    dataFiles.reset()
54                }
55                
56                Spacer().frame(height: 200)
57                
58                Button("Reset") {
59                    fileDownloadTask?.cancel()
60                    dataFiles.reset()
61                }
62                .buttonStyle(BlueButtonStyle())
63                
64                Spacer()
65            }
66            .padding()
67        }
68    }
69}

ViewModel

 1class DataFileViewModel7: ObservableObject {
 2    @Published private(set) var file: DataFile
 3    
 4    init() {
 5        self.file = DataFile(id: 1, fileSize: 10)
 6    }
 7    
 8    func downloadFile() async  -> Int {
 9        await MainActor.run {
10            file.isDownloading = true
11        }
12        
13        while file.isDownloading {
14            do {
15                try await MainActor.run {
16                    try Task.checkCancellation()
17                    
18                    print("  --   Downloading file - \(file.progress * 100) %")
19
20                    file.increment()
21                }
22                usleep(300000)
23            } catch {
24                print(" *************** Catch - The task has been cancelled")
25                // Set the isDownloading flag to false
26                await MainActor.run {
27                    file.isDownloading = false
28                    // reset()
29                }
30            }
31        }
32        
33        await MainActor.run {
34            file.isDownloading = false
35            print(" ***** File : \(file.id) setting isDownloading to FALSE")
36        }
37        
38        return 1
39    }
40        
41    @MainActor
42    func reset() {
43        self.file = DataFile(id: 1, fileSize: 10)
44    }
45}

Cancel a Task instance in SwiftUI
Cancel a Task instance in SwiftUI



Task cancellation propagates to child tasks - Task.isCancelled

An alternative to raising an exception with checkCancellation is to use isCancelled to see if a task has been cancelled or not.

This approach still uses a State property for the Task. This is assigned to the downloadFiles function in the download button and the task is cancelled with the cancel button in the view.

The difference is in the ViewModel where the while loop includes a check for !Task.isCancelled so that the download continues if it has not been cancelled. This approach can be used for the single file download or as the code below demonstrates for the multiple file download.

Note that the Task that is cancelled is the main downloadFiles, which has multiple async calls to download multiple files. When the main task is cancelled, the cancellation propagates to all of the child tasks automatically in Swift.


View

 1struct CancelTaskMultipleView: View {
 2    @ObservedObject private var dataFiles: DataFileViewModel8
 3    
 4    @State var fileDownloadTask: Task<Void, Error>?
 5    
 6    init() {
 7        dataFiles = DataFileViewModel8()
 8    }
 9    
10    var body: some View {
11        ScrollView {
12            VStack {
13                TitleView(title: ["Cancel Task", "", "<Task.isCancelled>"])
14                
15                HStack {
16                    Button("Download All") {
17                        fileDownloadTask = Task {
18                            await dataFiles.downloadFiles()
19                        }
20                    }
21                    .buttonStyle(BlueButtonStyle())
22                    .disabled(dataFiles.isDownloading)
23                    
24                    Button("Cancel") {
25                        fileDownloadTask?.cancel()
26                    }
27                    .buttonStyle(BlueButtonStyle())
28                    .disabled(!dataFiles.isDownloading)
29                }
30                
31                Text("Files Downloaded: \(dataFiles.fileCount)")
32                
33                ForEach(dataFiles.files) { file in
34                    HStack(spacing: 10) {
35                        Text("File \(file.id):")
36                        ProgressView(value: file.progress)
37                            .frame(width: 180)
38                        Text("\((file.progress * 100), specifier: "%0.0F")%")
39                        
40                        ZStack {
41                            Color.clear
42                                .frame(width: 30, height: 30)
43                            if file.isDownloading {
44                                ProgressView()
45                                    .progressViewStyle(CircularProgressViewStyle(tint: .blue))
46                            }
47                        }
48                    }
49                }
50                .padding()
51                
52                .onDisappear {
53                    fileDownloadTask?.cancel()
54                    dataFiles.reset()
55                }
56                
57                Spacer().frame(height: 150)
58                
59                Button("Reset") {
60                    fileDownloadTask?.cancel()
61                    dataFiles.reset()
62                }
63                .buttonStyle(BlueButtonStyle())
64                
65                Spacer()
66            }
67            .padding()
68        }
69    }
70}

ViewModel

 1class DataFileViewModel8: ObservableObject {
 2    @Published private(set) var files: [DataFile]
 3    @Published private(set) var fileCount = 0
 4    
 5    init() {
 6        files = [
 7            DataFile(id: 1, fileSize: 10),
 8            DataFile(id: 2, fileSize: 20),
 9            DataFile(id: 3, fileSize: 5)
10        ]
11    }
12    
13    var isDownloading : Bool {
14        files.filter { $0.isDownloading }.count > 0
15    }
16    
17    func downloadFiles() async {
18        async let num1 = await downloadFile(0)
19        async let num2 = await downloadFile(1)
20        async let num3 = await downloadFile(2)
21        let (result1, result2, result3) = await (num1, num2, num3)
22        await MainActor.run {
23            fileCount = result1 + result2 + result3
24        }
25    }
26    
27    private func downloadFile(_ index: Array<DataFile>.Index) async -> Int {
28        await MainActor.run {
29            files[index].isDownloading = true
30        }
31        
32        while files[index].isDownloading, !Task.isCancelled {
33            await MainActor.run {
34                print("  --   Downloading file \(files[index].id) - \(files[index].progress * 100) %")
35                files[index].increment()
36            }
37            usleep(300000)
38        }
39        
40        await MainActor.run {
41            files[index].isDownloading = false
42            print(" ***** File : \(files[index].id) setting isDownloading to FALSE")
43        }
44        return 1
45    }
46    
47    @MainActor
48    func reset() {
49        files = [
50            DataFile(id: 1, fileSize: 10),
51            DataFile(id: 2, fileSize: 20),
52            DataFile(id: 3, fileSize: 5)
53        ]
54    }
55}

Cancelling a task instance cancels child tasks in SwiftUI
Cancelling a task instance cancels child tasks in SwiftUI



Cancelling and resuming background tasks in SwiftUI

Cancelling and resuming background tasks in SwiftUI




Conclusion

In Asynchronous programming, it is important to stop any unneeded background tasks to save resources and avoid any unwanted side-effects from background tasks interfering with the App. The Swift Async framework provides a number of ways to indicate that a task has been cancelled, but it is up to the implementer of the code in the task to respond appropriately when the task has been cancelled. Once a task has been cancelled, it cannot be uncancelled. One way to check if a task has been cancelled is to use checkCancellation, which will throw an error. Another is to simply use isCancelled as aboolean flag to see if the task has been cancelled.

Source code for AsyncLetApp is available on GitHub.