deanWombourne
deanWombourne

Reputation: 38475

Binding to a read-only property in SwiftUI

I have a model type which looks like this:

enum State {
    case loading
    case loaded([String])
    case failed(Error)

    var strings: [String]? {
        switch self {
        case .loaded(let strings): return strings
        default: return nil
        }
    }
}

class MyApi: ObservableObject {
    private(set) var state: State = .loading

    func fetch() {
        ... some time later ...
        self.state = .loaded(["Hello", "World"])
    }
}

and I'm trying to use this to drive a SwiftUI View.

struct WordListView: View {
    @EnvironmentObject var api: MyApi

    var body: some View {
        ZStack {
            List($api.state.strings) {
                Text($0)
            }
        }
    }
}

It's about here that my assumptions fail. I'm trying to get a list of the strings to render in my List when they are loaded, but it won't compile.

The compiler error is Generic parameter 'Subject' could not be inferred, which after a bit of googling tells me that bindings are two-way, so won't work with both my private(set) and the var on the State enum being read-only.

This doesn't seem to make any sense - there is no way that the view should be able to tell the api whether or not it's loading, that definitely should be a one-way data flow!

I guess my question is either

  1. Is there a way to get a one-way binding in SwiftUI - i.e. some of the UI will update based on a value it cannot change.

or

  1. How should I have architected this code! It's very likely that I'm writing code in a style which doesn't work with SwiftUI, but all the tutorials I can see online neatly ignore things like loading / error states.

Upvotes: 9

Views: 10147

Answers (2)

Peter Suwara
Peter Suwara

Reputation: 816

Not exactly the same problem as I had, but the following direction can help you possibly find a good result when bindings are done with only reads.

You can create a custom binding using a computed property.

I needed to do exactly this in order to show an alert only when one was passed into an overlay.

Code looks something along these lines :

struct AlertState {
    var title: String
}

class AlertModel: ObservableObject {

    // Pass a binding to an alert state that can be changed at
    // any time.
    @Published var alertState: AlertState? = nil
    @Published var showAlert: Bool = false

    init(alertState: AnyPublisher<AlertState?, Never>) {
        alertState
            .assign(to: &$alertState)

        alertState
            .map { $0 != nil }
            .assign(to: &$showAlert)
    }
}

struct AlertOverlay<Content: View>: View {
    var content: Content
    @ObservedObject var alertModel: AlertModel

    init(
        alertModel: AlertModel,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.alertModel = alertModel
        self.content = content()
    }

    var body: some View {
        ZStack {
            content
                .blur(radius: alertModel.showAlert
                      ? UserInterfaceStandards.blurRadius
                      : 0)
        }
        .alert(isPresented: $alertModel.showAlert) {
            guard let alertState = alertModel.alertState else {
                return Alert(title: Text("Unexected internal error as occured."))
            }
            return Alert(title: Text(alertState.title))
        }
    }
}

Upvotes: 0

Alladinian
Alladinian

Reputation: 35616

You don't actually need a binding for this.

An intuitive way to decide if you need a binding or not is to ask:

Does this view need to modify the passed value ?

In your case the answer is no. The List doesn't need to modify api.state (as opposed to a textfield or a slider for example), it just needs the current value of it at any given moment. That is what @State is for but since the state is not something that belongs to the view (remember, Apple says that each state must be private to the view) you're correctly using some form of an ObservableObject (through Environment).

The final missing piece is to mark any of your properties that should trigger an update with @Published, which is a convenience to fire objectWillChange signals and instruct any observing view to recalculate its body.

So, something like this will get things done:

class MyApi: ObservableObject {
    @Published private(set) var state: State = .loading

    func fetch() {
        self.state = .loaded(["Hello", "World"])
    }
}

struct WordListView: View {
    @EnvironmentObject var api: MyApi

    var body: some View {
        ZStack {
            List(api.state.strings ?? [], id: \.self) {
                Text($0)
            }
        }
    }
}

Upvotes: 13

Related Questions