Is there a way to bind an optional to a Toggle / Slider with SwiftUI

Viewed 2972

I have a UInt? (optional) property in my data model that I am trying to bind to a Toggle and Slider with SwiftUI. I am trying to get to something like this:

  • maximumRingsShownCount have value 4 (not nil), then the toggle should be on and the value bound to the slider.
  • maximumExpandedRingsShownCount value is nil, then the toggle should off and the slider is not shown.

Toggle and Sliders rendered with SwiftUI

I am facing 2 issues here:

  1. It looks like we cannot have optional bindings (for the Slider)
  2. Is it possible to have a transformer to transform the optional to boolean (for the Toggle)?

So far, in my view I declared 2 properties in addition to my model:

@ObjectBinding var configuration: SunburstConfiguration

@State private var haveMaximumRingsShownCount: Bool = false
@State private var haveMaximumExpandedRingsShownCount: Bool = false

And my view body contains (for each property):

Toggle(isOn: $haveMaximumRingsShownCount) {
    Text("maximumRingsShownCount")
}
if haveMaximumRingsShownCount {
    VStack(alignment: .leading) {
        Text("maximumRingsShownCount = \(config.maximumRingsShownCount!)")
            Slider(value: .constant(4 /* no binding here :( */ ))
        }
    }
}

The UI layout is correct but I still have the issues mentioned above because:

  1. The haveMaximumRingsShownCount state is not bound to my config.maximumRingsShownCount model being nil or not
  2. The slider is currently just displaying a constant and not bound to the unwrapped config.maximumRingsShownCount property

Any ideas on how to solve these issue with optionals?

[ This can be reproduced in the sample code at https://github.com/lludo/SwiftSunburstDiagram ]

3 Answers

I found it convenient to overload the nil coalescing operator (??) to work with this situation.

// OptionalBinding.swift

import Foundation
import SwiftUI

func OptionalBinding<T>(_ binding: Binding<T?>, _ defaultValue: T) -> Binding<T> {
    return Binding<T>(get: {
        return binding.wrappedValue ?? defaultValue
    }, set: {
        binding.wrappedValue = $0
    })
}

func ??<T> (left: Binding<T?>, right: T) -> Binding<T> {
    return OptionalBinding(left, right)
}

You can then use it as follows:

struct OptionalSlider: View {
    @Binding var optionalDouble: Double?

    var body: some View {
        Slider(value: $optionalDouble ?? 0.0, in: 0.0...1.0)
    }
}

It's a bit tricky, but creating manually the Binding (by providing the getter and the setter) required for the view is the best solution I have found so far.

class DataModel: BindableObject {
    public let didChange = PassthroughSubject<Void, Never>()

    var maximumRingsShownCount: UInt? = 50 {
        didSet {
            didChange.send(())
        }
    }

    lazy private(set) var sliderBinding: Binding<Double> = {
        return Binding<Double>(getValue: {
            return Double(self.maximumRingsShownCount ?? 0) / 100.0
        }) { (value) in
            self.maximumRingsShownCount = UInt(value * 100)
        }
    }()

    lazy private(set) var toggleBinding: Binding<Bool> = {
        return Binding<Bool>(getValue: { () -> Bool in
            return self.maximumRingsShownCount != nil
        }, setValue: { (value) in
            self.maximumRingsShownCount = value ? 0 : nil
        })
    }()
}

struct ContentView : View {

    @ObjectBinding var model = DataModel()

    var body: some View {
        VStack {
            Toggle(isOn: model.toggleBinding) {
                Text("Enable")
            }

            if model.maximumRingsShownCount != nil {
                Text("max rings: \(model.maximumRingsShownCount!)")
                Slider(value: model.sliderBinding)
            }
        }.padding()
    }
}

As the Slider can only accept floating point numbers, the Binding handle the conversion between UInt and Double values.

Note : There is still a weird behaviour with the Toggle the first time its state is updated by a view event. I couldn't find a way to avoid this for now, but the code might still help you.

I've found another way without changing the model, basically, you just create a Binding object without the optional inside the view you are interested and then move your unwrapping logic there ie

struct OptionalSlider: View {
    private var optionalValue: Binding<Double>
    private var label: String
    private var hide: Bool

    var body: some View {
        Group {
            if hide {
                EmptyView()
            } else {
                HStack() {
                    Text(label)
                    Slider(value: optionalValue, in: 0.0...1.0)
                }
            }
        }
    }

    init(value: Binding<Double?>, label: String) {
        self.label = label
        self.optionalValue = Binding<Double>(get: {
               return value.wrappedValue ?? 0.0
           }, set: {
               value.wrappedValue = $0
           })
        self.hide = value.wrappedValue == nil
    }
}
Related