Search code examples
swiftswiftuiswift-concurrency

SwiftUI async task is cancelled when updating state var first time


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
    }
}
}

Solution

  • 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.