Search code examples
swiftuiobservedobjectswift-optionals

How to use published optional properties correctly for SwiftUI


To provide some context, Im writing an order tracking section of our app, which reloads the order status from the server every so-often. The UI on-screen is developed in SwiftUI. I require an optional image on screen that changes as the order progresses through the statuses.

When I try the following everything works...

My viewModel is an ObservableObject: internal class MyAccountOrderViewModel: ObservableObject {

This has a published property: @Published internal var graphicURL: URL = Bundle.main.url(forResource: "tracking_STAGEONE", withExtension: "gif")!

In SwiftUI use the property as follows: GIFViewer(imageURL: $viewModel.graphicURL)


My issue is that the graphicURL property has a potentially incorrect placeholder value, and my requirements were that it was optional. Changing the published property to: @Published internal var graphicURL: URL? causes an issue for my GIFViewer which rightly does not accept an optional URL:

Cannot convert value of type 'Binding<URL?>' to expected argument type 'Binding<URL>'

Attempting the obvious unwrapping of graphicURL produces this error:

Cannot force unwrap value of non-optional type 'Binding<URL?>'


What is the right way to make this work? I don't want to have to put a value in the property, and check if the property equals placeholder value (Ie treat that as if it was nil), or assume the property is always non-nil and unsafely force unwrap it somehow.


Solution

  • Below is an extension of Binding you can use to convert a type like Binding<Int?> to Binding<Int>?. In your case, it would be URL instead of Int, but this extension is generic so will work with any Binding:

    extension Binding {
        func optionalBinding<T>() -> Binding<T>? where T? == Value {
            if let wrappedValue = wrappedValue {
                return Binding<T>(
                    get: { wrappedValue },
                    set: { self.wrappedValue = $0 }
                )
            } else {
                return nil
            }
        }
    }
    

    With example view:

    struct ContentView: View {
        @StateObject private var model = MyModel()
    
        var body: some View {
            VStack(spacing: 30) {
                Button("Toggle if nil") {
                    if model.counter == nil {
                        model.counter = 0
                    } else {
                        model.counter = nil
                    }
                }
    
                if let binding = $model.counter.optionalBinding() {
                    Stepper(String(binding.wrappedValue), value: binding)
                } else {
                    Text("Counter is nil")
                }
            }
        }
    }
    
    class MyModel: ObservableObject {
        @Published var counter: Int?
    }
    

    Result:

    Result