Search code examples
iosswiftuiscrollview

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


Solution

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