1

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

George
  • 25,988
  • 10
  • 79
  • 133
Darren
  • 10,182
  • 20
  • 95
  • 162
  • You cannot put this type of code inside a view. You could try adding this to the Picker: `.onAppear { self.selectedInterface = result.selectedInterface }` – workingdog support Ukraine Aug 17 '21 at 10:38
  • Thanks. I see that does in fact update the `selectedInterface` but it doesn't seem to be selecting a segment. – Darren Aug 17 '21 at 11:10
  • @Darren Another way, is to do `let _ = ...`. For example to print within a view body, do `let _ = print("test")`. This method is different from `onAppear` because this happens every time the `body` is recomputed, not just the first time it appears. – George Aug 17 '21 at 15:52
  • John Sundell: *"It could definitely be argued that the above pattern works perfectly fine for simpler views — however, mixing view code with tasks like data loading and networking is not really considered a good practice, as doing so tends to lead to quite messy and intertwined implementations over time."* So, thanks for providing us an perfect example for this anti pattern ;) – CouchDeveloper Aug 17 '21 at 18:01

1 Answers1

1

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 !!
        }
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Although I can see the `wrappedValue` changes uses `print(selected.wrappedValue)`, it doesn't seem to be selecting a segment. – Darren Aug 17 '21 at 11:00
  • Try to use it in Picker as well – Asperi Aug 17 '21 at 11:11
  • No, still no luck. I confirm the `@State` is changing using `.onChange(of: selectedInterface)` to log, but still no segment is selected. It's like it's not redrawing the view when the `@State` changes. – Darren Aug 17 '21 at 11:16
  • I see - selection and items in Picker should be of same type - use either tag or change type of selection to String – Asperi Aug 17 '21 at 11:18
  • Thanks, I have it working now, but only if I set the selection to `String` and `.tag($0.name)`. Not sure why it won't work with using the `Interface` itself as the tag. – Darren Aug 17 '21 at 11:40