Best data-binding practice in Combine + SwiftUI?

13,464

Solution 1

An elegant way I found is to replace the error on the publisher with Never and to then use assign (assign only works if Failure == Never).

In your case...

dataPublisher
    .receive(on: DispatchQueue.main)
    .map { _ in "Data received" } //for the sake of the demo
    .replaceError(with: "An error occurred") //this sets Failure to Never
    .assign(to: \.stringValue, on: self)
    .store(in: &cancellableBag)

Solution 2

I think the missing piece here is that you are forgetting that your SwiftUI code is functional. In the MVVM paradigm, we split the functional part into the view model and keep the side effects in the view controller. With SwiftUI, the side effects are pushed even higher into the UI engine itself.

I haven't messed much with SwiftUI yet so I can't say I understand all the ramifications yet, but unlike UIKit, SwiftUI code doesn't directly manipulate screen objects, instead it creates a structure that will do the manipulation when passed to the UI engine.

Solution 3

After posting previous answer read this article: https://nalexn.github.io/swiftui-observableobject/

and decide to do same way. Use @State and don't use @Published

General ViewModel protocol:

protocol ViewModelProtocol {
    associatedtype Output
    associatedtype Input

    func bind(_ input: Input) -> Output
}

ViewModel class:

final class SwiftUIViewModel: ViewModelProtocol {
    struct Output {
        var dataPublisher: AnyPublisher<String, Never>
    }

    typealias Input = Void

    func bind(_ input: Void) -> Output {
        let dataPublisher = URLSession.shared.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
        .map{ "Just for testing - \($0)"}
        .replaceError(with: "An error occurred")
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()

        return Output(dataPublisher: dataPublisher)
    }
}

SwiftUI View:

struct ContentView: View {

    @State private var dataPublisher: String = "ggg"

    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)
        }
        .onReceive(output.dataPublisher) { value in
            self.dataPublisher = value
        }
    }
}

Solution 4

I ended up with some compromise. Using @Published in viewModel but subscribing in SwiftUI View. Something like this:

final class SwiftUIViewModel: ObservableObject {
    struct Output {
        var dataPublisher: AnyPublisher<String, Never>
    }

    @Published var dataPublisher : String = "ggg"

    func bind() -> Output {
        let dataPublisher = URLSession.shared.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
        .map{ "Just for testing - \($0)"}
        .replaceError(with: "An error occurred")
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()

        return Output(dataPublisher: dataPublisher)
    }
}

and SwiftUI:

struct ContentView: View {
    private var cancellableBag = Set<AnyCancellable>()

    @ObservedObject var viewModel: SwiftUIViewModel

    init(viewModel: SwiftUIViewModel) {
        self.viewModel = viewModel

        let bindStruct = viewModel.bind()
        bindStruct.dataPublisher
            .assign(to: \.dataPublisher, on: viewModel)
            .store(in: &cancellableBag)
    }

    var body: some View {
        VStack {
            Text(self.viewModel.dataPublisher)
        }
    }
}

Share:
13,464

Related videos on Youtube

Enrico Querci
Author by

Enrico Querci

I'm an iOS developer and a UI designer. I make it my goal to create apps which are both easy to use and effective.

Updated on February 22, 2020

Comments

  • Enrico Querci
    Enrico Querci about 4 years

    In RxSwift it's pretty easy to bind a Driver or an Observable in a View Model to some observer in a ViewController (i.e. a UILabel).

    I usually prefer to build a pipeline, with observables created from other observables, instead of "imperatively" pushing values, say via a PublishSubject).

    Let's use this example: update a UILabel after fetching some data from the network


    RxSwift + RxCocoa example

    final class RxViewModel {
        private var dataObservable: Observable<Data>
    
        let stringDriver: Driver<String>
    
        init() {
            let request = URLRequest(url: URL(string:"https://www.google.com")!)
    
            self.dataObservable = URLSession.shared
                .rx.data(request: request).asObservable()
    
            self.stringDriver = dataObservable
                .asDriver(onErrorJustReturn: Data())
                .map { _ in return "Network data received!" }
        }
    }
    
    final class RxViewController: UIViewController {
        private let disposeBag = DisposeBag()
        let rxViewModel = RxViewModel()
    
        @IBOutlet weak var rxLabel: UILabel!
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            rxViewModel.stringDriver.drive(rxLabel.rx.text).disposed(by: disposeBag)
        }
    }
    

    Combine + UIKit example

    In a UIKit-based project it seems like you can keep the same pattern:

    • view model exposes publishers
    • view controller binds its UI elements to those publishers
    final class CombineViewModel: ObservableObject {
        private var dataPublisher: AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure>
        var stringPublisher: AnyPublisher<String, Never>
    
        init() {
            self.dataPublisher = URLSession.shared
                .dataTaskPublisher(for: URL(string: "https://www.google.it")!)
                .eraseToAnyPublisher()
    
            self.stringPublisher = dataPublisher
                .map { (_, _) in return "Network data received!" }
                .replaceError(with: "Oh no, error!")
                .receive(on: DispatchQueue.main)
                .eraseToAnyPublisher()
        }
    }
    
    final class CombineViewController: UIViewController {
        private var cancellableBag = Set<AnyCancellable>()
        let combineViewModel = CombineViewModel()
    
        @IBOutlet weak var label: UILabel!
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            combineViewModel.stringPublisher
                .flatMap { Just($0) }
                .assign(to: \.text, on: self.label)
                .store(in: &cancellableBag)
        }
    }
    

    What about SwiftUI?

    SwiftUI relies on property wrappers like @Published and protocols like ObservableObject, ObservedObject to automagically take care of bindings (As of Xcode 11b7).

    Since (AFAIK) property wrappers cannot be "created on the fly", there's no way you can re-create the example above using to the same pattern. The following does not compile

    final class WrongViewModel: ObservableObject {
        private var dataPublisher: AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure>
        @Published var stringValue: String
    
        init() {
            self.dataPublisher = URLSession.shared
                .dataTaskPublisher(for: URL(string: "https://www.google.it")!)
                .eraseToAnyPublisher()
    
            self.stringValue = dataPublisher.map { ... }. ??? <--- WRONG!
        }
    }
    

    The closest I could come up with is subscribing in your view model (UGH!) and imperatively update your property, which does not feel right and reactive at all.

    final class SwiftUIViewModel: ObservableObject {
        private var cancellableBag = Set<AnyCancellable>()
        private var dataPublisher: AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure>
    
        @Published var stringValue: String = ""
    
        init() {
            self.dataPublisher = URLSession.shared
                .dataTaskPublisher(for: URL(string: "https://www.google.it")!)
                .eraseToAnyPublisher()
    
            dataPublisher
                .receive(on: DispatchQueue.main)
                .sink(receiveCompletion: {_ in }) { (_, _) in
                self.stringValue = "Network data received!"
            }.store(in: &cancellableBag)
        }
    }
    
    struct ContentView: View {
        @ObservedObject var viewModel = SwiftUIViewModel()
    
        var body: some View {
            Text(viewModel.stringValue)
        }
    }
    

    Is the "old way of doing bindings" to be forgotten and replaced, in this new UIViewController-less world?

    • Benjamin Kindle
      Benjamin Kindle over 4 years
      I don't think there is any built-in way to do what you want. This is a helper function that someone made that you may find interesting though.
    • Michael Salmon
      Michael Salmon over 4 years
      There are two ways of handling asynchronous data in SwiftUI or perhaps one way with two variants. You can use onReceive as Benjamin suggested or save the data in a class and send an objectWillChange message. I've used both and they are pretty easy to use. The biggest drawback with onReceive that I have seen is that it can be affected by body being reread due to the view's state changing, see stackoverflow.com/questions/57796877/… which has problems if both timers are 1 second-
  • sergiy batsevych
    sergiy batsevych over 3 years
    Check github.com/serbats/Reactive-Combine-MVVM-Templates for some MVVM Xcode templates