I'm making an application for macOS with SwiftUI. I have a button that starts a download process asynchronously by calling Task.init
and an async function. When the task starts, I set a @State busy
variable to true
so I can display a ProgressView
while the download is happening. The problem is if I switch to a different view then back while it is downloading, the state gets reset although the task still runs. How can I make it so it remembers the state, or maybe check if the task is still running?
Here is a stripped down example:
import SwiftUI
struct DownloadRow: View {
let content: Content
@State var busy = false
var body: some View {
if !busy {
Button() {
Task.init {
busy = true
await content.download()
busy = false
}
} label: {
Label("Download")
}
} else {
ProgressView()
}
}
}
CodePudding user response:
You could put the variable that tracks the download's progress into an ObservableObject
class and make the progress variable @Published
. Have the object that tracks your progress as an @ObservedObject
in the view. By doing so, you decouple the progress tracker from the view's lifecycle. Make sure this view does not initialize the progress tracker, or a new object will be built when the view is built again.
CodePudding user response:
A Task
should not live longer than it owner/initiator. Here is a simple visualization of how it can be managed.
import SwiftUI
struct DownloadRow: View {
@State var task : Task<Void, Never>?
var body: some View {
Group{
//Give access to the button that starts the task
if task == nil || task?.isCancelled ?? false{
Button {
//Hold on to the task so it survives the like of the @State
task = Task.init {
try? await download()
//Adjust the view by setting back to nil when done
task = nil
}
} label: {
Label("Download", systemImage: "arrow.down")
}
//Prevent making the call twice by using the task to determine if the button should be disabled
.disabled(task != nil)
}
//present a progress view if the task is running
else {
ProgressView()
}
}.onDisappear(){
//If the group happens to disappear the task should be cancelled
task?.cancel()
}
}
func download() async throws {
try await Task.sleep(nanoseconds: 4_000_000_000)
}
}
struct DownloadRow_Previews: PreviewProvider {
static var previews: some View {
DownloadRow()
}
}
Assuming that you are using a list of some time you likely want to task to live beyond the row view so you would need something like this.
import SwiftUI
@MainActor
class DownloadController: ObservableObject, Identifiable{
let id: UUID = .init()
@Published var task : Task<Void, Never>?
@Published var runCount: Int = 0
func runTask(){
//Hold on to the task so it survives the like of the @State
task = Task.init {
try? await download()
//Adjust the view by setting back to nil when done
task = nil
runCount = 1
}
}
private func download() async throws {
try await Task.sleep(nanoseconds: 4_000_000_000)
}
}
@MainActor
struct DownloadList: View{
//Controllers have to "live" above the row so they can survive the row disappearing
let controllers: [DownloadController] = [.init(),.init(),.init()]
var body: some View{
NavigationView{
List(controllers) { controller in
NavigationLink(controller.id.uuidString) {
DownloadRow(controller: controller)
}
}
//Because you shouldnt leave task running cancel them if this view disappears.
.onDisappear(){
let conts = controllers.filter { cont in
cont.task != nil || !(cont.task?.isCancelled ?? true)
}
conts.forEach { cont in
cont.task?.cancel()
}
}
}
}
}
struct DownloadRow: View {
@ObservedObject var controller: DownloadController
var body: some View {
Group{
//Give access to the button that starts the task
if controller.task == nil || controller.task?.isCancelled ?? false{
Button {
controller.runTask()
} label: {
Label("Download \(controller.runCount)", systemImage: "arrow.down")
}
//Prevent making the call twice by using the task to determine if the button should be disabled
.disabled(controller.task != nil)
}
//present a progress view if the task is running
else {
ProgressView()
}
}
}
}
struct DownloadRow_Previews: PreviewProvider {
static var previews: some View {
DownloadList()
//DownloadRow(controller: DownloadController())
}
}