Home > Back-end >  Remember @State variable after switching views
Remember @State variable after switching views

Time:10-25

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())
    }
}
  • Related