msmialko
msmialko

Reputation: 1619

Custom SwiftUI view with a two-way binding

I'm struggling to implement a custom view which can take Binding as an argument and implement two-way updates of that value.

So basically I'm implementing my custom slider and want its initializer to be like this:

MySlider(value: <Binding<Float>)


What I'm struggling with:

  1. How do I subscribe to remote updates of the binding value so that I can update the view's state?
  2. Is there any nice way to bind a Binding with @State property?

Here's my current implementation so far which is not perfect.

struct MySlider: View {

    @Binding var selection: Float?
    @State private var selectedValue: Float?

    init(selection: Binding<Float?>) {
        self._selection = selection

        // https://stackoverflow.com/a/58137096
        _selectedValue = State(wrappedValue: selection.wrappedValue)
    }

    var body: some View {
         HStack(spacing: 3) {
             ForEach(someValues) { (v) in
                 Item(value: v,
                      isSelected: v == self.selection)
                     .onTapGesture {
                         // No idea how to do that other way so I don't have to set it twice
                         self.selection = v
                         self.selectedValue = v
                    }
             }
         }
    }
}

Edit 1: I suppose my problem is that the underlying model object comes from Core Data and wasn't owned by any SwiftUI view which would observe its changes. The model object was owned by the UIKit ViewController and I was passing only a Binding to the SwiftUI view which is not enough. My solution now is to pass the model object also to the SwiftUI View so that it can marked it as an @ObservedObject.

struct MySlider<T>: View where T: ObservableObject {

    @ObservedObject var object: T
    @Binding var selection: Float?

    var body: some View {
        return ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 3) {
                ForEach(values) { (v) in
                    Item(value: v,
                         isSelected: v == self.selection)
                        .onTapGesture {
                            self.selection = v
                    }
                }
            }
        }
    }
}

Upvotes: 3

Views: 5916

Answers (2)

LoVo
LoVo

Reputation: 2083

My answer might be a little late, but i stumbled across a similar problem. So here is how i solved it.

struct Item : Identifiable {
    var id : Int
    var isSelected : Bool
}

class MySliderObserver : ObservableObject {
   
   @Published var values = [Item]()
    
    init() {
        values.append(Item(id: 0, isSelected: false))
        values.append(Item(id: 1, isSelected: false))
        values.append(Item(id: 2, isSelected: false))
    }
    
    func toggleSelectForItem(id:Int) -> Void {
        guard let index = values.firstIndex(where: { $0.id == id }) else {
            return
        }

        var item = values[index]
        item.isSelected.toggle()
        values[index] = item
    }
}

struct MySlider: View {

    @EnvironmentObject var observer : MySliderObserver

    var body: some View {
        return ScrollView(.horizontal, showsIndicators: false) {
            VStack(spacing: 3) {

                ForEach(self.observer.values) { v in
                    Text("Selected: \(v.isSelected ? "true" : "false")").onTapGesture {
                        self.observer.toggleSelectForItem(id: v.id)
                    }
                }
            }
        }
    }
}

and in the SceneDelegate you need to set an EnvironmentObject like:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let contentView = MySlider()

        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView.environmentObject(MySliderObserver()))
            self.window = window
            window.makeKeyAndVisible()
        }
    }

Upvotes: 0

TheNeil
TheNeil

Reputation: 3752

The definition of @Binding is essentially a two-way connection to an underlying data item, such a a @State variable that's owned by another View. As stated by Apple:

Use a binding to create a two-way connection between a view and its underlying model.

If it's a binding, SwiftUI will already update its views automatically if the value changes; so (in answer to your first question), you don't need to do any sort of subscriptions to update your custom view - that will happen automatically.

Similarly (regarding your second question), because the binding is the state from another view, you shouldn't be declaring it as a state for your view as well, and I also don't believe it's possible. State is something that should be a purely internal value to your view (Apple strongly recommend that all @State properties are declared private).

This all ties back to the 'Single Source of Truth' concept Apple stressed when unveiling SwiftUI: The parent view where that binding's already @State is what owns the information, so it's not something your view should also declare as a state.

For your code, I think all you need to do is take out that second state property, because it's not required. Just make sure the binding you pass is a @State property in whatever parent view owns your custom view, and pass it in using the $ syntax to create that binding. This article covers the idea in more detail if you need.

struct MySlider: View {

    @Binding var selection: Float?

    init(selection: Binding<Float?>) {
        self._selection = selection
    }

    var body: some View {
         HStack(spacing: 3) {
             ForEach(someValues) { (v) in
                 Item(value: v, isSelected: v == self.selection)
                     .onTapGesture {
                         self.selection = v
                    }
             }
         }
    }
}

Upvotes: 2

Related Questions