Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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

like image 547
Jobie J Avatar asked Dec 05 '25 08:12

Jobie J


1 Answers

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.

like image 180
lorem ipsum Avatar answered Dec 07 '25 20:12

lorem ipsum



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!