Serhii R
Serhii R

Reputation: 92

How pass Published var from one class (view model) as Published property in other class (view model) for binding to view of second view model

I have FirstViewModel class with @Published property "myProperty". In view FirstView I insert SecondView which has environmentObject SecondViewModel. SecondViewModel also has own @Published var myProperty. myProperty in FirstViewModel and SecondViewModel must be the same (trigger each other). How I can pass myProperty of FirstViewModel into SecondViewModel for bindint to SecondView. SecondViewModel and SecondView must not know about FirstViewModel and FirstView.

import SwiftUI
import Combine

class FirstViewModel: ObservableObject {
    @Published var myProperty: String?
    // Some code and some methods
    // In this class I handle "myProperty"

    private var cancellables = Set<AnyCancellable>()

    init() {
        self.myProperty = "Two"
    }
}

struct FirstView: View {

    @EnvironmentObject var vm: FirstViewModel

    var body: some View {

        SecondView()
           .environmentObject(SecondViewModel(myProperty: $vm.myProperty))
    }

}


class SecondViewModel: ObservableObject {
    @Published var myProperty: String?
    // Some code
}

struct SecondView: View {

    @EnvironmentObject var vm: SecondViewModel
    let items: [String] = ["One", "Two", "Three"]

    var body: some View {

        Picker(
            selection: $vm.myProperty,
            label: EmptyView()
        ) {
            ForEach(vm.items, id: \.self) { item in
                Text(item).tag(item)
            }
        }
    }

}

Another words I need pass @Published var myProperty from FirstViewModel to SecondViewModel for binding to SecondView.

This code don't work, I got error "Cannot convert value of type 'Binding<String?>' to expected argument type 'String?'"

Upvotes: 3

Views: 1563

Answers (4)

Javier Heisecke
Javier Heisecke

Reputation: 1222

This is how you pass around a published variable to another view model, and remember to clean the cancellable FirstViewModel

class FirstViewModel: ObservableObject {
    @Published var myProperty: String?

    var secondViewModel: SecondViewModel {
       .init(myProperty: $myProperty)
    }
}

SecondViewModel

class SecondViewModel: ObservableObject {
    @Published var myProperty: String?
    var subscriptions = Set<AnyCancellable>()

    init(myProperty: Published<String?>.Publisher) {
        myProperty
          .receive(on: DispatchQueue.main)
          .assign(to: \.myProperty, on: self)
          .store(in: &subscriptions)  
    }
}

Upvotes: 0

Khaled Sh
Khaled Sh

Reputation: 21

In views, you listen to published properties using @StateObject or @ObservedObject, for classes however you use combine to listen to a published property from another class.

In the first ViewModel:

  • Create global shared instance of the class itself (singleton)

In the second ViewModel:

  • Import Combine
  • setup a cancelable variable
  • reference the shared instance from first class
  • Setup function to subscribe to a property in the first class
  • Put the function in the initializer In essence:
class FirstViewModel
{
  @Published var firstProperty

  static let shared = FirstViewModel()
}

class SecondViewModel
{
  @Published secondProperty

  private var cancellables = Set<AnyCancellable>()
  private let firstViewModel = FirstViewModel.shared

  init() {
    setupSubscribers()
  }

  func setupSubscribers() {
    firstViewModel.$firstProperty.sink { [weak self] firstProperty in
      self?.secondProperty = firstProperty
    }
    .store(in: &cancellables)
  }
}

Upvotes: 0

natSegOS
natSegOS

Reputation: 31

In order for them to be in sync and be able to make changes to each other, I think they would have to know about each other somehow or at least one or the other has to know about the other.

The way I see it, SecondView only exists under FirstView, so FirstView will exist as long as SecondView exists. Therefore, it may be a good idea to use dependency injection to inject FirstViewModel (which also only exists as long as FirstView exists) as a dependency into SecondView like so:

struct FirstView: View {
    ...
    var body: some View {
         SecondView()
            .environmentObject(SecondViewModel())
            .environmentObject(FirstViewModel())
    }
}

struct SecondView: View {
    @EnvironmentObject var firstViewModel: FirstViewModel
    @EnvironmentObject var viewModel: SecondViewModel
    ...
}

One thing you might be able to do is create an extension to help sync to bindable properties, which just requires them both to conform to the Equatable protocol (and Strings do conform to this protocol) like so:

extension View {
    func sync<Bindable: Equatable>(_ published: Binding<Bindable>, with binding: Binding<Bindable>) -> some View {
        self
            .onChange(of: published.wrappedValue) { published in
                binding.wrappedValue = published
            }
            .onChange(of: binding.wrappedValue) { binding in
                published.wrappedValue = binding
            }
    }
}

You can then make use of this in your code like so:

struct SecondView: View {
    @EnvironmentObject var firstViewModel: FirstViewModel
    @EnvironmentObject var viewModel: SecondViewModel
    ...
    var body: some View {
        Picker(
            ...
        ) {
            ...
        }
        .sync($firstViewModel.myProperty, with: $viewModel.myProperty) // Note the order in which u put the properties u wanna sync doesn't matter
    }
}

Let me know if you need any clarification or if this does not work. Happy to help!

Upvotes: 1

jnpdx
jnpdx

Reputation: 52565

Instead of a @Published property in your second view model, you should use a @Binding so there's a single source of truth (the first view model):


class FirstViewModel: ObservableObject {
    @Published var myProperty: String

    init() {
        self.myProperty = "Two"
    }
}

struct FirstView: View {

    @EnvironmentObject var vm: FirstViewModel

    var body: some View {
        TextField("", text: $vm.myProperty)
        SecondView()
           .environmentObject(SecondViewModel(myProperty: $vm.myProperty))
    }

}


class SecondViewModel: ObservableObject {
    @Binding var myProperty: String
    
    init(myProperty: Binding<String>) {
        _myProperty = myProperty
    }
}

struct SecondView: View {
    @EnvironmentObject var vm: SecondViewModel

    var body: some View {
        TextField("", text: vm.$myProperty)
    }
}

I've simplified the example a little bit so that it compiles. You can easily change the types back from String to String? if you remove the TextFields that I just have to show the behavior.

Probably worth mentioning (since otherwise it will almost certainly appear in the comments) that there are some that will disagree with the architecture choice of using multiple view models like this, but I think that's outside of the scope of the question, which I took at face value.

Upvotes: 1

Related Questions