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