user12440639
user12440639

Reputation:

Bidirectional binding with SwiftUI and Combine

I'm trying to work out how I can correctly pass an object or a set of values between two ViewModels in a parent-child relationship so that when the child ViewModel is updated the change bubbles back up to the parent.

This is pretty simple when just using SwiftUI views and binding directly to the stores but I wanted to keep my business logic for field validation and so on separate from the SwiftUI views.

The code below shows the child updating (as expected) when the parent gets updated, but I need to somehow pass the changed values in the child back up to the parent. I'm very new to mobile app development and still learning so I'm sure I'm missing something quite simple.

import SwiftUI
import Combine

struct Person: Hashable {
  var givenName: String
  var familyName: String
}

// my person store - in the real app it's backed by coredata
class PersonStore: ObservableObject {
  @Published var people: [Person] = [
    Person(
      givenName: "Test",
      familyName: "Person"
    )
  ]
  static let shared = PersonStore()
}

// app entrypoint
struct PersonView: View {
  @ObservedObject var viewModel: PersonView_ViewModel = PersonView_ViewModel()

  var body: some View {
    NavigationView {
      VStack {
        List(viewModel.people.indices, id: \.self) { idx in
          NavigationLink(destination: PersonDetailView(viewModel: PersonDetailView_ViewModel(personIndex: idx))) {
            Text(self.viewModel.people[idx].givenName)
          }
        }
      }
    }
  }
}

class PersonView_ViewModel: ObservableObject {
  @Published var people: [Person] = PersonStore.shared.people
}

// this is the detail view
struct PersonDetailView: View {
  @ObservedObject var viewModel: PersonDetailView_ViewModel

  var body: some View {
    Form {
      Section(header: Text("Parent View")) {
        VStack {
          TextField("Given Name", text: self.$viewModel.person.givenName)
          Divider()
          TextField("Family Name", text: self.$viewModel.person.familyName)
        }
      }
      PersonBasicDetails(viewModel: PersonBasicDetails_ViewModel(person: viewModel.person))
    }
  }
}

// viewmodel associated with detail view
class PersonDetailView_ViewModel: ObservableObject {
  @Published var person: Person

  init(personIndex: Int) {
    self.person = PersonStore.shared.people[personIndex]
  }
}

// this is the child view - in the real app there are multiple sections which are conditionally rendered
struct PersonBasicDetails: View {
  @ObservedObject var viewModel: PersonBasicDetails_ViewModel

  var body: some View {
    Section(header: Text("Child View")) {
      VStack {
        TextField("Given Name", text: self.$viewModel.person.givenName)
        Divider()
        TextField("Family Name", text: self.$viewModel.person.familyName)
      }
    }
  }
}

class PersonBasicDetails_ViewModel: ObservableObject {
  @Published var person: Person

  init(person: Person) {
    self.person = person
  }
}

struct PersonView_Previews: PreviewProvider {
  static var previews: some View {
    PersonView()
  }
}

Upvotes: 3

Views: 3118

Answers (2)

E.Coms
E.Coms

Reputation: 11531

If you want two way works, not only you need to publish, also you have to use binding for upward.

struct Person: Hashable {
    var givenName: String
    var familyName: String
}

// my person store - in the real app it's backed by coredata
class PersonStore: ObservableObject {
    @Published var people: [Person] = [
        Person(givenName: "Test",familyName: "Person")
    ]

    static let shared = PersonStore()
}

// app entrypoint
struct PersonView: View {
    @ObservedObject var viewModel: PersonView_ViewModel = PersonView_ViewModel()

    var body: some View {
        NavigationView {
            VStack {
                List(viewModel.people.indices, id: \.self) { idx in
                    NavigationLink(destination: PersonDetailView(viewModel: PersonDetailView_ViewModel(person: self.$viewModel.people , index: idx ))) {
                        Text(self.viewModel.people[idx].givenName)
                    }
                }
            }
        }
    }
}

class PersonView_ViewModel: ObservableObject {
    @Published var people: [Person] = PersonStore.shared.people
}

// this is the detail view
struct PersonDetailView: View {
    @ObservedObject var viewModel: PersonDetailView_ViewModel

    var body: some View {
        Form {
            Section(header: Text("Parent View")) {
                VStack {
                    TextField("Given Name", text: self.viewModel.person.givenName)
                    Divider()
                    TextField("Family Name", text: self.viewModel.person.familyName)
                }
            }
             PersonBasicDetails(viewModel: PersonBasicDetails_ViewModel(person: viewModel.person))
        }
    }
}

// viewmodel associated with detail view
class PersonDetailView_ViewModel: ObservableObject {
    @Published var person: Binding<Person>

    init(person: Binding<[Person]> ,index: Int) {
        self.person = person[index]
    }
}

// this is the child view - in the real app there are multiple sections which are conditionally rendered
struct PersonBasicDetails: View {
    @ObservedObject var viewModel: PersonBasicDetails_ViewModel

    var body: some View {
        Section(header: Text("Child View")) {
            VStack {
                TextField("Given Name", text: self.viewModel.person.givenName)
                Divider()
                TextField("Family Name", text: self.viewModel.person.familyName)
            }
        }
    }
}

class PersonBasicDetails_ViewModel: ObservableObject {
    @Published var person: Binding<Person>

    init(person: Binding<Person>) {
        self.person = person //person
    }
}

struct PersonView_Previews: PreviewProvider {
    static var previews: some View {
        PersonView()
    }
}

Upvotes: 2

Gil Birman
Gil Birman

Reputation: 35900

In most SwiftUI TextField examples around the web the binding is provided by utilizing a @State variable which creates an instance of Binding for you.

However, you can also create a custom binding using the Binding constructor. Here's an example of what that looks like:

TextField(
  "Given Name",
  text: Binding(
    get: { self.$viewModel.person.givenName },
    set: { self.$viewModel.person.givenName = $0 }))

Upvotes: 4

Related Questions