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")
}
}
}
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.