Search code examples
swiftasync-awaitswiftuiswift5swift-concurrency

Updating a @State property from within a SwiftUI View


I have an AsyncContentView that handles the loading of data when the view appears and handles the switching of a loading view and the content (Taken from here swiftbysundell):

struct AsyncContentView<P:Parsable, Source:Loader<P>, Content: View>: View {
    
    @ObservedObject private var source: Source
    private var content: (P.ReturnType) -> Content
    
    init?(source: Source, reloadAfter reloadTime:UInt64 = 0, @ViewBuilder content: @escaping (P.ReturnType) -> Content) {
        self.source = source
        self.content = content
    }
    
    func loadInfo() {
        Task {
            await source.loadData()
        }
    }
    
    var body: some View {
        switch source.state {
        case .idle:
            return AnyView(Color.clear.onAppear(perform: loadInfo))
        case .loading:
            return AnyView(ProgressView("Loading..."))
        case .loaded(let output):
            return AnyView(content(output))
        }
    }
}

For completeness, here's the Parsable protocol:

protocol Parsable: ObservableObject {
    associatedtype ReturnType
    init()
    var result: ReturnType { get }
}

And the LoadingState and Loader

enum LoadingState<Value> {
    case idle
    case loading
    case loaded(Value)
}

@MainActor
class Loader<P:Parsable>: ObservableObject {
    @Published public var state: LoadingState<P.ReturnType> = .idle
    func loadData() async {
        self.state = .loading
        await Task.sleep(2_000_000_000)
        self.state = .loaded(P().result)
    }
}

Here is some dummy data I am using:

struct Interface: Hashable {
    let name:String
}
struct Interfaces {
    let interfaces: [Interface] = [
        Interface(name: "test1"),
        Interface(name: "test2"),
        Interface(name: "test3")
    ]
    var selectedInterface: Interface { interfaces.randomElement()! }
}

Now I put it all together like this which does it's job. It processes the async function which shows the loading view for 2 seconds, then produces the content view using the supplied data:

struct ContentView: View {
    
    class SomeParsableData: Parsable {
        typealias ReturnType = Interfaces
        required init() { }
        var result = Interfaces()
    }
    
    @StateObject var pageLoader: Loader<SomeParsableData> = Loader()
    @State private var selectedInterface: Interface?
    
    var body: some View {
        AsyncContentView(source: pageLoader) { result in
            Picker(selection: $selectedInterface, label: Text("Selected radio")) {
                ForEach(result.interfaces, id: \.self) {
                    Text($0.name)
                }
            }
            .pickerStyle(.segmented)
        }
    }
}

Now the problem I am having, is this data contains which segment should be selected. In my real app, this is a web request to fetch data that includes which segment is selected.

So how can I have this view update the selectedInterface @state property?

If I simply add the line

self.selectedInterface = result.selectedInterface

into my AsyncContentView I get this error

Type '()' cannot conform to 'View'

enter image description here


Solution

  • You can do it in onAppear of generated content, but I suppose it is better to do it not directly but via binding (which is like a reference to state's external storage), like

    var body: some View {
        let selected = self.$selectedInterface
        AsyncContentView(source: pageLoader) { result in
            Picker(selection: selected, label: Text("Selected radio")) {
                ForEach(result.interfaces, id: \.self) {
                    Text($0.name).tag(Optional($0))       // << here !!
                }
            }
            .pickerStyle(.segmented)
            .onAppear {
                selected.wrappedValue = result.selectedInterface  // << here !!
            }
        }
    }