SwiftUI async task is cancelled when updating state var first time

117 Views Asked by At

I'm new to SwiftUI and Swift Concurrency and trying to write an abstraction for the UI to handle loading, error, and content states. I have tried both writing an ObservableObject ViewModel as well as just using a SwiftUI View. When updating the state var to be .loading, it causes the task to be in the cancelled state vs executing the task. If I remove the updating of the state var to .loading, the task runs fine. Or if I retry the task after the first attempt it works fine even if I set .loading. Can someone explain why the line uiState = FetchState.loading would cause my async task to be cancelled and how would I go about updating the state so it does not go into cancelled?

Enum for the states:

enum FetchState<T> {
    case none, loading, success(T), failure
}

ViewModel (this line causes the task to be cancelled: uiState = FetchState.loading):

@MainActor class FetchVM<T> : ObservableObject {

    private var fetch: () async throws -> T?

    init(fetch: @escaping () async throws -> T?) {
        self.fetch = fetch
    }
    @Published var uiState: FetchState<T> = FetchState.none
    public func fetch() async {
        uiState = FetchState.loading // This causes task to cancel for some reason
        do {
            guard let response = try await fetch() else {return}
            uiState = FetchState.success(response)
        } catch let error {
            print(error.localizedDescription)
            uiState = FetchState.failure
        }
    }
}

SwiftUI Client code:

struct CategoriesScreen: View {
    @StateObject var vm = FetchVM(fetch: Api().fakeProductCategories)
    var body: some View {
    VStack(alignment: .leading) {
        switch (vm.uiState){
            case .none:
                Color.clear.task {
                    await vm.fetch()
                }
            case .loading:
                ProgressView()
            case .failure:
                Text("There was an unknown error.  Please check your internet and try again.")
                Spacer()
                Button("Retry") {
                    Task {
                        await vm.fetch()
                    }
                }
            case .success(let data):
                Text("Display data")
        }
    }
}

My fake API code:

public struct Api {
    public func fakeProductCategories() async throws -> [CategoryItem]? {
         try await Task.sleep(nanoseconds: 1_000_000_000)
         return [
            CategoryItem(id: UUID())
         ]
    }
}

CategoryItem is just a simple struct with UUID field.

I tried writing the same implementation without using an ObservableObject and the same cancellation occurs when updating state to .loading:

struct ViewStateCoordinator<T, Content: View>: View {

@State var uiState: FetchState<T> = FetchState.none
private var fetch: () async throws -> T?
private var content: (T) -> Content

init(
    fetch: @escaping () async throws -> T?,
    @ViewBuilder content: @escaping (T) -> Content
) {
    self.fetch = fetch
    self.content = content
}

var body: some View {

    switch (uiState){
        case .none:
            Color.clear.task {
                await callServer()
            }
        case .loading:
            ProgressView()
        case .failure:
                Text("There was an unknown error.  Please check your internet and try again.")
                Spacer()
                Button("Retry") {
                    Task {
                        await callServer()
                    }
                }
        case .success(let data):
            content(data)
    }

}

private func callServer() async {
    uiState = FetchState.loading // this causes the task to be cancelled but Retry button works the second time
    do {
        guard let response = try await fetch() else {return}
        uiState = FetchState.success(response)
    } catch let error{
        print(error.localizedDescription)
        uiState = FetchState.failure
    }
}
}
2

There are 2 best solutions below

5
Sweeper On BEST ANSWER

The task is cancelled because you put the task on the Color.clear.

switch (vm.uiState){
    case .none:
        Color.clear.task {
            await vm.fetch()
        }

The documentation of task says:

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.

As soon as you set uiState to something else, Color.clear disappears, because the switch has switched to the .loading case. Therefore, the task is cancelled.

To prevent the task from being cancelled, put the task modifier on a view that lives longer. You can surround the switch with a Group:

Group {
    switch (uiState){
        ...
    }
}
.task {
    await callServer()
}

Group has no effect on the view hierarchy at all. It merely makes it possible for you to add view modifiers to the switch "itself", rather than individual views in each case of the switch.

1
julltron On

Any time you change state, whether it's definted by @State, @StateObject, or @ObservedObject, the entire view is destroyed and recreated by SwiftUI. It looks like the view is just being updated, but behind the scenes it's more destructive than that. The reason it doesn't happen the second time is because the state is being changed to the same value, so SwiftUI won't refresh the view.

The effect here is that as soon as you call your vm.fetch() or callServer() method, the view is destroyed, taking the task with it, because the method immediately changes the state. (The task might still run, but the result is lost with the view.)

To prevent this from happening, your view should simply observe the state of the API call and never set it. Move the tasks out of the view and into the ViewModel in your first example, so the asynchronous call is happening within the FetchVM, which isn't going to be destroyed when you update its state. Like this:

FetchVM.swift

import Foundation

enum FetchState<T> {
    case none, loading, success(T), failure
}

@MainActor class FetchVM<T> : ObservableObject {
    private var fetch: () async throws -> T?
    
    init(fetch: @escaping () async throws -> T?) {
        self.fetch = fetch
    }
    
    @Published var uiState: FetchState<T> = FetchState.none
    
    public func asyncFetch() {
        uiState = FetchState.loading
        Task {
            do {
                guard let response = try await fetch() else {return}
                uiState = FetchState.success(response)
            } catch let error {
                print(error.localizedDescription)
                uiState = FetchState.failure
            }
        }
    }
}

CategoriesScreen.swift

import SwiftUI

struct CategoriesScreen: View {
    @StateObject var vm = FetchVM(fetch: Api().fakeProductCategories)
    
    var body: some View {
        VStack(alignment: .leading) {
            switch (vm.uiState){
            case .none:
                Color.clear
                // moved fetch to onAppear
            case .loading:
                ProgressView()
            case .failure:
                Text("There was an unknown error.  Please check your internet and try again.")
                Spacer()
                Button("Retry") {
                    vm.asyncFetch()
                }
            case .success(let data):
                Text("Display \(data.first?.id.uuidString ?? "NO ID")")
            }
        }
        .onAppear {
            vm.asyncFetch()
        }
    }
}

CategoryItem.swift - added minimal definition to fulfill type dependency

import Foundation

public struct CategoryItem: Identifiable {
    public let id: UUID
}

Api.swift - unchanged

import Foundation

public struct Api {
    public func fakeProductCategories() async throws -> [CategoryItem]? {
        try await Task.sleep(nanoseconds: 1_000_000_000)
        return [
            CategoryItem(id: UUID())
        ]
    }
}