I use the following UIViewController
and RxSwift/RxCocoa
based piece of code to write a very simply MVVM pattern to bind a UIButton
tap event to trigger some Observable
work and listen for the result:
import UIKit
import RxSwift
import RxCocoa
class ViewController: UIViewController {
@IBOutlet weak var someButton: UIButton!
var viewModel: ViewModel!
private var disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
viewModel = ViewModel()
setupBindings()
}
private func setupBindings() {
someButton.rx.tap
.bind(to: self.viewModel.input.trigger)
.disposed(by: disposeBag)
viewModel.output.result
.subscribe(onNext: { element in
print("element is \(element)")
}).disposed(by: disposeBag)
}
}
class ViewModel {
struct Input {
let trigger: AnyObserver<Void>
}
struct Output {
let result: Observable<String>
}
let input: Input
let output: Output
private let triggerSubject = PublishSubject<Void>()
init() {
self.input = Input(trigger: triggerSubject.asObserver())
let resultObservable = triggerSubject.flatMap { Observable.just("TEST") }
self.output = Output(result: resultObservable)
}
}
It compiles and runs well. However, I need to Combin
ify this pattern with SwiftUI
, so I converted that code into the following:
import SwiftUI
import Combine
struct ContentView: View {
var viewModel: ViewModel
var subscriptions = Set<AnyCancellable>()
init(viewModel: ViewModel) {
self.viewModel = viewModel
setupBindings()
}
var body: some View {
Button(action: {
// <---- how to trigger viewModel's trigger from here
}, label: {
Text("Click Me")
})
}
private func setupBindings() {
self.viewModel.output.result.sink(receiveValue: { value in
print("value is \(value)")
})
.store(in: &subscriptions) // <--- doesn't compile due to immutability of ContentView
}
}
class ViewModel {
struct Input {
let trigger: AnySubscriber<Void, Never>
}
struct Output {
let result: AnyPublisher<String, Never>
}
let input: Input
let output: Output
private let triggerSubject = PassthroughSubject<Void, Never>()
init() {
self.input = Input(trigger: AnySubscriber(triggerSubject))
let resultPublisher = triggerSubject
.flatMap { Just("TEST") }
.eraseToAnyPublisher()
self.output = Output(result: resultPublisher)
}
}
This sample doesn't compile due to two errors (commented in code):
(1) Problem 1: How to trigger the publisher's work from the button's action closure like the case of RxSwift
above ?
(2) Problem 2 is related somehow to architectural design rather than a compile error:
the error says: ... Cannot pass immutable value as inout argument: 'self' is immutable ...
, that's because SwiftUI
views are structs, they are designed to be changed only through sorts of bindings (@State
, @ObservedObject
, etc ...), I have two sub-questions related to problem 2:
[A]: is it considered a bad practice to sink
a publisher in a SwiftUI
View ? which may need some workaround to store the cancellable
at the View
's struct scope ?
[B]: which one is better for SwiftUI/Combine
projects in terms of MVVM architectural pattern: using a ViewModel with [ Input[Subscribers], Output[AnyPublishers] ] pattern, or a
ObservableObject
ViewModel with [ @Published
properties] ?
I had same problem understanding best mvvm approach. Recommend also look into this thread Best data-binding practice in Combine + SwiftUI?
Will post my working example. Should be easy to convert to what you want.
SwiftUI View:
struct ContentView: View {
@State private var dataPublisher: String = "ggg"
@State private var sliderValue: String = "0"
@State private var buttonOutput: String = "Empty"
let viewModel: SwiftUIViewModel
let output: SwiftUIViewModel.Output
init(viewModel: SwiftUIViewModel) {
self.viewModel = viewModel
self.output = viewModel.bind(())
}
var body: some View {
VStack {
Text(self.dataPublisher)
Text(self.sliderValue)
Slider(value: viewModel.$sliderBinding, in: 0...100, step: 1)
Button(action: {
self.viewModel.buttonBinding = ()
}, label: {
Text("Click Me")
})
Text(self.buttonOutput)
}
.onReceive(output.dataPublisher) { value in
self.dataPublisher = value
}
.onReceive(output.slider) { (value) in
self.sliderValue = "\(value)"
}
.onReceive(output.resultPublisher) { (value) in
self.buttonOutput = value
}
}
}
AbstractViewModel:
protocol ViewModelProtocol {
associatedtype Output
associatedtype Input
func bind(_ input: Input) -> Output
}
ViewModel:
final class SwiftUIViewModel: ViewModelProtocol {
struct Output {
let dataPublisher: AnyPublisher<String, Never>
let slider: AnyPublisher<Double, Never>
let resultPublisher: AnyPublisher<String, Never>
}
typealias Input = Void
@SubjectBinding var sliderBinding: Double = 0.0
@SubjectBinding var buttonBinding: Void = ()
func bind(_ input: Void) -> Output {
let dataPublisher = URLSession.shared.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
.delay(for: 5.0, scheduler: DispatchQueue.main)
.map{ "Just for testing - \($0)"}
.replaceError(with: "An error occurred")
.receive(on: DispatchQueue.main)
.share()
.eraseToAnyPublisher()
let resultPublisher = _buttonBinding.anyPublisher()
.dropFirst()
.flatMap { Just("TEST") }
.share()
.eraseToAnyPublisher()
return Output(dataPublisher: dataPublisher,
slider: _sliderBinding.anyPublisher(),
resultPublisher: resultPublisher)
}
}
SubjectBinding property wrapper:
@propertyWrapper
struct SubjectBinding<Value> {
private let subject: CurrentValueSubject<Value, Never>
init(wrappedValue: Value) {
subject = CurrentValueSubject<Value, Never>(wrappedValue)
}
func anyPublisher() -> AnyPublisher<Value, Never> {
return subject.eraseToAnyPublisher()
}
var wrappedValue: Value {
get {
return subject.value
}
set {
subject.value = newValue
}
}
var projectedValue: Binding<Value> {
return Binding<Value>(get: { () -> Value in
return self.subject.value
}) { (value) in
self.subject.value = value
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With