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
}
}
}
The task is cancelled because you put the
taskon theColor.clear.The documentation of
tasksays:As soon as you set
uiStateto something else,Color.cleardisappears, because theswitchhas switched to the.loadingcase. Therefore, the task is cancelled.To prevent the task from being cancelled, put the
taskmodifier on a view that lives longer. You can surround theswitchwith aGroup:Grouphas no effect on the view hierarchy at all. It merely makes it possible for you to add view modifiers to theswitch"itself", rather than individual views in eachcaseof theswitch.