Best data-binding practice in Combine + SwiftUI? - ios

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?

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)

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.

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
}
}
}

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)
}
}
}

You can also extend CurrentValueSubject to expose a Binding as demonstrated in this Gist. Namely thus:
extension CurrentValueSubject {
var binding: Binding<Output> {
Binding(get: {
self.value
}, set: {
self.send($0)
})
}
}

Related

Build and update lists automatically with SwiftUI and Combine

I'm starting to learn SwiftUI development, I'm making my first basic SwiftUI based news application which I plan on open sourcing but I'm currently stuck. I've been reading Apple's documentation and looking at examples on how to automatically handle data changes in SwiftUI using combine etc. I've found an article, that's suppose to automatically update the list. I haven't been able to see any immediate data changes or anything being logged.
I'm using the same structure as NewsAPI but as an example I've uploaded it to a GitHub repo. I've made a small project and tried updating the data in my repo and trying to see any changes made in my data. I'm honestly trying my best and could really use some pointers or corrections in what my errors may be. I think my confusion lies in #ObservedObject and #Published and how to handle any changes in my content view. The article doesn't show anything they did to handle data changes so maybe I'm missing something?
import Foundation
import Combine
struct News : Codable {
var articles : [Article]
}
struct Article : Codable,Hashable {
let description : String?
let title : String?
let author: String?
let source: Source
let content: String?
let publishedAt: String?
}
struct Source: Codable,Hashable {
let name: String?
}
class NewsData: ObservableObject {
#Published var news: News = News(articles: [])
init() {
guard let url = URL(string: "https://raw.githubusercontent.com/ca13ra1/data/main/data.json") else { return }
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
if let response = try? JSONDecoder().decode(News.self, from: data) {
DispatchQueue.main.async() {
self.news = response
print("data called")
}
}
}
}
.resume()
}
}
My View
import SwiftUI
import Combine
struct ContentView: View {
#ObservedObject var data: NewsData
var body: some View {
List(data.news.articles , id: \.self) { article in
Text(article.title ?? "")
}
}
}
The data binding in SwiftUI does no extend to synching state with the server. If thats what you want then you need to use some other mechanism to tell the client that there is new data in the server (ie GraphQL, Firebase, send a push, use a web socket, or poll the server).
A simple polling solution would look like this and note you should not be doing network requests in init, only when you get an on appear from the view because SwiftUI eager hydrates its views even when you cannot see them. Similarly you need to cancel polling when you are off screen:
struct Article: Codable, Identifiable {
var id: String
}
final class ViewModel: ObservableObject {
#Published private(set) var articles: [Article] = []
private let refreshSubject = PassthroughSubject<Void, Never>()
private var timerSubscription: AnyCancellable?
init() {
refreshSubject
.map {
URLSession
.shared
.dataTaskPublisher(for: URL(string: "someURL")!)
.map(\.data)
.decode(type: [Article].self, decoder: JSONDecoder())
.replaceError(with: [])
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.assign(to: &$articles)
}
func refresh() {
refreshSubject.send()
guard timerSubscription == nil else { return }
timerSubscription = Timer
.publish(every: 5, on: .main, in: .common)
.autoconnect()
.sink(receiveValue: { [refreshSubject] _ in
refreshSubject.send()
})
}
func onDisappear() {
timerSubscription = nil
}
}
struct MyView: View {
#StateObject private var viewModel = ViewModel()
var body: some View {
List(viewModel.articles) { article in
Text(article.id)
}
.onAppear { viewModel.refresh() }
.onDisappear { viewModel.onDisappear() }
}
}
I've found a nifty swift package which allows me to easily repeat network calls. It's called swift-request. Thanks to #pawello2222 for helping me solve my dilemma.
import Request
class NewsData: ObservableObject {
#Published var news: News = News(articles: [])
init() {
test()
}
func test() {
AnyRequest<News> {
Url("https://raw.githubusercontent.com/ca13ra1/data/main/data.json")
}
.onObject { data in
DispatchQueue.main.async() {
self.news = data
}
}
.update(every: 300)
.update(publisher: Timer.publish(every: 300, on: .main, in: .common).autoconnect())
.call()
}
}
It's now working as expected, probably the easier option.
Demo:

Using URLSession to load JSON data for SwiftUI Views

What are proven approaches for structuring the networking layer of a SwiftUI app? Specifically, how do you structure using URLSession to load JSON data to be displayed in SwiftUI Views and handling all the different states that can occur properly?
Here is what I came up with in my last projects:
Represent the loading process as a ObservableObject model class
Use URLSession.dataTaskPublisher for loading
Using Codable and JSONDecoder to decode the response to Swift types using the Combine support for decoding
Keep track of the state in the model as a #Published property so that the view can show loading/error states.
Keep track of the loaded results as a #Published property in a separate property for easy usage in SwiftUI (you could also use View#onReceive to subscribe to the publisher directly in SwiftUI but keeping the publisher encapsulated in the model class seemed more clean overall)
Use the SwiftUI .onAppear modifier to trigger the loading if not loaded yet.
Using the .overlay modifier is convenient to show a Progress/Error view depending on the state
Extract reusable components for repeatedly occuring tasks (here is an example: EndpointModel)
Standalone example code for that approach (also available in my SwiftUIPlayground):
// SwiftUIPlayground
// https://github.com/ralfebert/SwiftUIPlayground/
import Combine
import SwiftUI
struct TypiTodo: Codable, Identifiable {
var id: Int
var title: String
}
class TodosModel: ObservableObject {
#Published var todos = [TypiTodo]()
#Published var state = State.ready
enum State {
case ready
case loading(Cancellable)
case loaded
case error(Error)
}
let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
let urlSession = URLSession.shared
var dataTask: AnyPublisher<[TypiTodo], Error> {
self.urlSession
.dataTaskPublisher(for: self.url)
.map { $0.data }
.decode(type: [TypiTodo].self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
func load() {
assert(Thread.isMainThread)
self.state = .loading(self.dataTask.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case let .failure(error):
self.state = .error(error)
}
},
receiveValue: { value in
self.state = .loaded
self.todos = value
}
))
}
func loadIfNeeded() {
assert(Thread.isMainThread)
guard case .ready = self.state else { return }
self.load()
}
}
struct TodosURLSessionExampleView: View {
#ObservedObject var model = TodosModel()
var body: some View {
List(model.todos) { todo in
Text(todo.title)
}
.overlay(StatusOverlay(model: model))
.onAppear { self.model.loadIfNeeded() }
}
}
struct StatusOverlay: View {
#ObservedObject var model: TodosModel
var body: some View {
switch model.state {
case .ready:
return AnyView(EmptyView())
case .loading:
return AnyView(ActivityIndicatorView(isAnimating: .constant(true), style: .large))
case .loaded:
return AnyView(EmptyView())
case let .error(error):
return AnyView(
VStack(spacing: 10) {
Text(error.localizedDescription)
.frame(maxWidth: 300)
Button("Retry") {
self.model.load()
}
}
.padding()
.background(Color.yellow)
)
}
}
}
struct TodosURLSessionExampleView_Previews: PreviewProvider {
static var previews: some View {
Group {
TodosURLSessionExampleView(model: TodosModel())
TodosURLSessionExampleView(model: self.exampleLoadedModel)
TodosURLSessionExampleView(model: self.exampleLoadingModel)
TodosURLSessionExampleView(model: self.exampleErrorModel)
}
}
static var exampleLoadedModel: TodosModel {
let todosModel = TodosModel()
todosModel.todos = [TypiTodo(id: 1, title: "Drink water"), TypiTodo(id: 2, title: "Enjoy the sun")]
todosModel.state = .loaded
return todosModel
}
static var exampleLoadingModel: TodosModel {
let todosModel = TodosModel()
todosModel.state = .loading(ExampleCancellable())
return todosModel
}
static var exampleErrorModel: TodosModel {
let todosModel = TodosModel()
todosModel.state = .error(ExampleError.exampleError)
return todosModel
}
enum ExampleError: Error {
case exampleError
}
struct ExampleCancellable: Cancellable {
func cancel() {}
}
}
Splitting off the state / data / networking into a separate #ObservableObject class outside the View Struct is definitely the way to go. There are too many SwiftUI "Hello World" examples out there stuffing it all into the View struct.
As a best practice you could look to standardize your #ObservableObject naming inline with MVVM and call that "Model" class a ViewModel, as in:
#StateObject var viewModel = TodosViewModel()
The majority of code in there is handling overlay state, onAppear events and display issues for the View.
Create a new TodosModel class and reference that in the ViewModel:
#ObservedObject var model = TodosModel()
Then move all the networking / api / JSON code into that class with one method called by ViewModel:
public func getList() -> AnyPublisher<[TypiTodo], Error>
The View-ViewModel-Model are now split up, related to Paul D's comment, the ViewModel could combine 1 or more Models to return whatever the view needs. And, more importantly, the TodoModel entity knows nothing about the View and can focus on http / JSON / CRUD.
Below is great example using Combine / HTTP / JSON decode. You can see how it uses tryMap, mapError to further separate the networking from the decode errors. https://gist.github.com/stinger/e8b706ab846a098783d68e5c3a4f0ea5
See a very short and clear explanation of the difference between #StateObject and #ObservedObject in this article:
https://levelup.gitconnected.com/state-vs-stateobject-vs-observedobject-vs-environmentobject-in-swiftui-81e2913d63f9

SwiftUI: How can I catch changing value from observed object when I execute function

I have a problem with observed object in SwiftUI.
I can see changing values of observed object on the View struct.
However in class or function, even if I change text value of TextField(which is observable object) but "self.codeTwo.text still did not have changed.
here's my code sample (this is my ObservableObject)
class settingCodeTwo: ObservableObject {
private static let userDefaultTextKey = "textKey2"
#Published var text: String = UserDefaults.standard.string(forKey: settingCodeTwo.userDefaultTextKey) ?? ""
private var canc: AnyCancellable!
init() {
canc = $text.debounce(for: 0.2, scheduler: DispatchQueue.main).sink { newText in
UserDefaults.standard.set(newText, forKey: settingCodeTwo.userDefaultTextKey)
}
}
deinit {
canc.cancel()
}
}
and the main problem is... "self.codeTwo.text" never changed!
class NetworkManager: ObservableObject {
#ObservedObject var codeTwo = settingCodeTwo()
#Published var posts = [Post]()
func fetchData() {
var urlComponents = URLComponents()
urlComponents.scheme = "http"
urlComponents.host = "\(self.codeTwo.text)" //This one I want to use observable object
urlComponents.path = "/mob_json/mob_json.aspx"
urlComponents.queryItems = [
URLQueryItem(name: "nm_sp", value: "UP_MOB_CHECK_LOGIN"),
URLQueryItem(name: "param", value: "1000|1000|\(Gpass.hahaha)")
]
if let url = urlComponents.url {
print(url)
let session = URLSession(configuration: .default)
let task = session.dataTask(with: url) { (data, response, error) in
if error == nil {
let decoder = JSONDecoder()
if let safeData = data {
do {
let results = try decoder.decode(Results.self, from: safeData)
DispatchQueue.main.async {
self.posts = results.Table
}
} catch {
print(error)
}
}
}
}
task.resume()
}
}
}
and this is view, I can catch change of the value in this one
import SwiftUI
import Combine
struct SettingView: View {
#ObservedObject var codeTwo = settingCodeTwo()
var body: some View {
ZStack {
Rectangle().foregroundColor(Color.white).edgesIgnoringSafeArea(.all).background(Color.white)
VStack {
TextField("test", text: $codeTwo.text).textFieldStyle(BottomLineTextFieldStyle()).foregroundColor(.blue)
Text(codeTwo.text)
}
}
}
}
Help me please.
Non-SwiftUI Code
Use ObservedObject only for SwiftUI, your function / other non-SwiftUI code will not react to the changes.
Use a subscriber like Sink to observe changes to any publisher. (Every #Published variable has a publisher as a wrapped value, you can use it by prefixing with $ sign.
Reason for SwiftUI View not reacting to class property changes:
struct is a value type so when any of it's properties change then the value of the struct has changed
class is a reference type, when any of it's properties change, the underlying class instance is still the same.
If you assign a new class instance then you will notice that the view reacts to the change.
Approach:
Use a separate view and that accepts codeTwoText as #Binding that way when the codeTwoText changes the view would update to reflect the new value.
You can keep the model as a class so no changes there.
Example
class Model : ObservableObject {
#Published var name : String //Ensure the property is `Published`.
init(name: String) {
self.name = name
}
}
struct NameView : View {
#Binding var name : String
var body: some View {
return Text(name)
}
}
struct ContentView: View {
#ObservedObject var model : Model
var body: some View {
VStack {
Text("Hello, World!")
NameView(name: $model.name) //Passing the Binding to name
}
}
}
Testing
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let model = Model(name: "aaa")
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
model.name = "bbb"
}
return ContentView(model: model)
}
}
It is used two different instances of SettingCodeTwo - one in NetworkNamager another in SettingsView, so they are not synchronised if created at same time.
Here is an approach to keep those two instances self-synchronised (it is possible because they use same storage - UserDefaults)
Tested with Xcode 11.4 / iOS 13.4
Modified code below (see also important comments inline)
extension UserDefaults {
#objc dynamic var textKey2: String { // helper keypath
return string(forKey: "textKey2") ?? ""
}
}
class SettingCodeTwo: ObservableObject { // use capitalised name for class !!!
private static let userDefaultTextKey = "textKey2"
#Published var text: String = UserDefaults.standard.string(forKey: SettingCodeTwo.userDefaultTextKey) ?? ""
private var canc: AnyCancellable!
private var observer: NSKeyValueObservation!
init() {
canc = $text.debounce(for: 0.2, scheduler: DispatchQueue.main).sink { newText in
UserDefaults.standard.set(newText, forKey: SettingCodeTwo.userDefaultTextKey)
}
observer = UserDefaults.standard.observe(\.textKey2, options: [.new]) { _, value in
if let newValue = value.newValue, self.text != newValue { // << avoid cycling on changed self
self.text = newValue
}
}
}
}
class NetworkManager: ObservableObject {
var codeTwo = SettingCodeTwo() // no #ObservedObject needed here
...

Binding a SwiftUI Button to AnySubscriber like RxCocoa's button tap

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 Combinify 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
}
}
}
So I recently was also wondering how I would do this since we are not starting to write out views in SwiftUI.
I made a helper object the encapsulates the transition from a function call to a Publisher. I called it a Relay.
#available(iOS 13.0, *)
struct Relay<Element> {
var call: (Element) -> Void { didCall.send }
var publisher: AnyPublisher<Element, Never> {
didCall.eraseToAnyPublisher()
}
// MARK: Private
private let didCall = PassthroughSubject<Element, Never>()
}
In your case specifically, you would be able to declare a private Relay and use it like so;
Button(action: relay.call,
label: {
Text("Click Me")
})
And then you can do whatever you like with.
relay.publisher

ObservedObject inside ObservableObject not refreshing View

I'm trying to display an activity indicator when performing an async request.
What I did is creating an ActivityTracker object that will track life cycle of a publisher.
This ActivityTracker is an ObservableObject and will be stored in the view model which also is an ObservableObject.
It seems that this kind of setup isn't refreshing the View. Here's my code:
struct ContentView: View {
#ObservedObject var viewModel = ContentViewModel()
var body: some View {
VStack(spacing: 16) {
Text("Counter: \(viewModel.tracker.count)\nPerforming: \(viewModel.tracker.isPerformingActivity ? "true" : "false")")
Button(action: {
_ = request().trackActivity(self.viewModel.tracker).sink { }
}) {
Text("Request")
}
}
}
}
class ContentViewModel: ObservableObject {
#Published var tracker = Publishers.ActivityTracker()
}
private func request() -> AnyPublisher<Void, Never> {
return Just(()).delay(for: 2.0, scheduler: RunLoop.main)
.eraseToAnyPublisher()
}
extension Publishers {
final class ActivityTracker: ObservableObject {
// MARK: Properties
#Published var count: Int = 0
var isPerformingActivity: Bool {
return count > 0
}
private var cancellables: [AnyCancellable] = []
private let counterSubject = CurrentValueSubject<Int, Never>(0)
private let lock: NSRecursiveLock = .init()
init() {
counterSubject.removeDuplicates()
.receive(on: RunLoop.main)
.print()
.sink { [weak self] counter in
self?.count = counter
}
.store(in: &cancellables)
}
// MARK: Private methods
fileprivate func trackActivity<Value, Error: Swift.Error>(
ofPublisher publisher: AnyPublisher<Value, Error>
) {
publisher
.receive(on: RunLoop.main)
.handleEvents(
receiveSubscription: { _ in self.increment() },
receiveOutput: nil,
receiveCompletion: { _ in self.decrement() },
receiveCancel: { self.decrement() },
receiveRequest: nil
)
.print()
.sink(receiveCompletion: { _ in }, receiveValue: { _ in })
.store(in: &cancellables)
}
private func increment() {
lock.lock()
defer { lock.unlock() }
counterSubject.value += 1
}
private func decrement() {
lock.lock()
defer { lock.unlock() }
counterSubject.value -= 1
}
}
}
extension AnyPublisher {
func trackActivity(_ activityTracker: Publishers.ActivityTracker) -> AnyPublisher {
activityTracker.trackActivity(ofPublisher: self)
return self
}
}
I also tried to declare my ActivityTracker as #Published but same result, my text is not updated.
Note that storing the activity tracker directly in the view will work but this is not what I'm looking for.
Did I miss something here ?
Nested ObservableObjects is not supported yet.
When you want to use these nested objects, you need to notify the objects by yourself when data got changed.
I hope the following code can help you with your problem.
First of all use: import Combine
Then declare your model and submodels, they all need to use the #ObservableObject property to work. (Do not forget the #Published property aswel)
I made a parent model named Model and two submodels Submodel1 & Submodel2. When you use the parent model when changing data e.x: model.submodel1.count, you need to use a notifier in order to let the View update itself.
The AnyCancellables notifies the parent model itself, in that case the View will be updated automatically.
Copy the code and use it by yourself, then try to remake your code while using this. Hope this helps, goodluck!
class Submodel1: ObservableObject {
#Published var count = 0
}
class Submodel2: ObservableObject {
#Published var count = 0
}
class Model: ObservableObject {
#Published var submodel1 = Submodel1()
#Published var submodel2 = Submodel2()
var anyCancellable: AnyCancellable? = nil
var anyCancellable2: AnyCancellable? = nil
init() {
anyCancellable = submodel1.objectWillChange.sink { [weak self] (_) in
self?.objectWillChange.send()
}
anyCancellable2 = submodel2.objectWillChange.sink { [weak self] (_) in
self?.objectWillChange.send()
}
}
}
When you want to use this Model, just use it like normal usage of the ObservedObjects.
struct Example: View {
#ObservedObject var obj: Model
var body: some View {
Button(action: {
self.obj.submodel1.count = 123
// If you've build a complex layout and it still won't work, you can always notify the modal by the following line of code:
// self.obj.objectWillChange.send()
}) {
Text("Change me")
}
}
If you have a collection of stuff you can do this:
import Foundation
import Combine
class Submodel1: ObservableObject {
#Published var count = 0
}
class Submodel2: ObservableObject {
var anyCancellable: [AnyCancellable] = []
#Published var submodels: [Submodel1] = []
init() {
submodels.forEach({ submodel in
anyCancellable.append(submodel.objectWillChange.sink{ [weak self] (_) in
self?.objectWillChange.send()
})
})
}
}

Resources