Home > Mobile >  Why is async task cancelled in a refreshable Modifier on a ScrollView (iOS 16)
Why is async task cancelled in a refreshable Modifier on a ScrollView (iOS 16)

Time:01-03

I'm trying to use the refreshable modifier on a Scrollview in an app that targets iOS 16. But, the asynchronus task gets cancelled during the pull to refresh gesture.

Here is some code and an attached video that demonstrates the problem and an image with the printed error:

ExploreViemModel.swift

class ExploreViewModel: ObservableObject {
    
    @Published var randomQuotes: [Quote] = []
    
    init() {
        Task {
            await loadQuotes()
        }
    }
     
    @MainActor
    func loadQuotes() async {
         
        let quotesURL = URL(string: "https://type.fit/api/quotes")!
        
        do {
            let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
            guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
            
            if response.statusCode == 200 {
                let quotes =  try JSONDecoder().decode([Quote].self, from: data)
                randomQuotes.append(contentsOf: quotes)
            }
        } catch {
            debugPrint(error)
            debugPrint(error.localizedDescription)
        }
    }
    
    func clearQuotes() {
        randomQuotes.removeAll()
    }
}

ContentView.swift

import SwiftUI

struct ContentView: View {
    
    @StateObject private var exploreVM = ExploreViewModel()
    
    var body: some View {
        
        NavigationStack {
            ExploreView()
                .environmentObject(exploreVM)
                .refreshable {
                    exploreVM.clearQuotes()
                    await exploreVM.loadQuotes()
                }
        }
    }
}

Explore.swift

import SwiftUI

struct ExploreView: View {
    
    @EnvironmentObject var exploreVM: ExploreViewModel
 
    var body: some View {
        ScrollView {
            VStack {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 140.0), spacing: 24.0)], spacing: 24.0) {
                    ForEach(exploreVM.randomQuotes) { quote in
                        VStack(alignment: .leading) {
                            Text("\(quote.text ?? "No Text")")
                                .font(.headline)
                            Text("\(quote.author ?? "No Author")")
                                .font(.caption)
                        }
                        .frame(minWidth: 0, maxWidth: .infinity)
                        .frame(height: 144.0)
                        .border(Color.red, width: 2.0)
                        
                    }

                }
            }
            .padding()
            .navigationTitle("Explore")
        }
 
    }
}

Cancelled Task

Printed Error

CodePudding user response:

When you call exploreVM.clearQuotes() you cause the body to redraw when the array is cleared.

.refreshable also gets redrawn so the previous "Task" that is being used is cancelled.

This is just the nature of SwiftUI.

There are a few ways of overcoming this, this simplest is to "hold-on" to the task by using an id.

Option 1

struct ExploreParentView: View {
    @StateObject private var exploreVM = ExploreViewModel()
    //@State can survive reloads on the `View`
    @State private var taskId: UUID = .init()
    var body: some View {
        NavigationStack {
            ExploreView()
                .refreshable {
                    print("refreshable")
                    //Cause .task to re-run by changing the id.
                    taskId = .init()
                }
            //Runs when the view is first loaded and when the id changes.
            //Task is perserved while the id is preserved.
                .task(id: taskId) {
                    print("task \(taskId)")
                    exploreVM.clearQuotes()
                    await exploreVM.loadQuotes()
                }
        }.environmentObject(exploreVM)
    }
}

If you use the above method you should remove the "floating" Task you have in the init of the ExploreViewModel.

Option 2

The other way is preventing a re-draw until the url call has returned.

class ExploreViewModel: ObservableObject {
    //Remove @Published
    var randomQuotes: [Quote] = []
    
    init() {
        //Floading Task that isn't needed for option 1
        Task {
            await loadQuotes()
        }
    }
     
    @MainActor
    func loadQuotes() async {
        
        let quotesURL = URL(string: "https://type.fit/api/quotes")!
        
        do {
            let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
            guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
            
            if response.statusCode == 200 {
                let quotes =  try JSONDecoder().decode([Quote].self, from: data)
                randomQuotes.append(contentsOf: quotes)
                print("updated")
            }
        } catch {
            debugPrint(error)
            debugPrint(error.localizedDescription)
        }
        print("done")
        //Tell the View to redraw
        objectWillChange.send()
    }
    
    func clearQuotes() {
        randomQuotes.removeAll()
    }
}

Option 3

Is to wait to change the array until there is a response.

class ExploreViewModel: ObservableObject {
    @Published var randomQuotes: [Quote] = []
    
    init() {
        Task {
            await loadQuotes()
        }
    }
     
    @MainActor
    func loadQuotes() async {
        
        let quotesURL = URL(string: "https://type.fit/api/quotes")!
        
        do {
            let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
            guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
            
            if response.statusCode == 200 {
                let quotes =  try JSONDecoder().decode([Quote].self, from: data)
                //Replace array
                randomQuotes = quotes
                print("updated")
            }
        } catch {
            //Clear array
            clearQuotes()
            debugPrint(error)
            debugPrint(error.localizedDescription)
        }
        print("done")
    }
    
    func clearQuotes() {
        randomQuotes.removeAll()
    }
}

Option 1 is more resistant to cancellation it is ok for short calls. It isn't going to wait for the call to return to dismiss the ProgressView.

Option 2 offers more control from within the ViewModel but the view can still be redrawn by someone else.

Option 3 is likely how Apple envisioned the process going but is also vulnerable to other redraws.

CodePudding user response:

The point of async/await and .task is to remove the need for a reference type. Try this instead:

struct ContentView: View {
    
    @State var randomQuotes: [Quote] = []
    
    var body: some View {
        NavigationStack {
            ExploreView()
                .refreshable {
                    await loadQuotes()
                }
        }
    }

      func loadQuotes() async {
         
        let quotesURL = URL(string: "https://type.fit/api/quotes")!
        
        do {
            let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
            guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
            
            if response.statusCode == 200 {
                quotes = try JSONDecoder().decode([Quote].self, from: data)
            }
        } catch {
            debugPrint(error)
            debugPrint(error.localizedDescription)

            // usually we store the error in another state.
        }
    }
}
  • Related