How to pass a Publisher to a class - swift

I have a model class which holders a few publishers as the source of truth. And I also have a few classes that processes data. I need to process data depending on a publisher from the model class.
class Model: ObservableObject {
#Published var records = [Record]()
let recordProcessor: RecordProcessor
init() {
...
}
}
class RecordProcessor: ObservableObject {
#Published var results = [Result]()
}
struct RootView: View {
var body: some View {
MyView()
.environmentObject(Model())
}
}
struct MyView: View {
#EnvironmentObject var model: Model
var body: some View {
ForEach(model.recordProcessor.results) { ... }
}
}
RecordProcessor does a lot of work on records so the work is encapsulated into a class, but the input is the records stored on the Model. What is a proper way of passing in the records to the RecordProcessor?

Assuming Record is a value type, here is possible way (as far as I understood your goal)
class Model: ObservableObject {
#Published var records = [Record]() {
didSet {
recordProcessor.process(records) // << something like this
}
}
let recordProcessor: RecordProcessor
init(processor: RecordProcessor) {
self.recordProcessor = processor
}
}
class RecordProcessor: ObservableObject {
#Published var results = [Result]()
func process(_ records: [Record]) {
}
}

The best I'm come up with is to have the RecordProcessor process the data and return a publisher with the results:
struct Record {}
struct Result {}
class Model: ObservableObject {
#Published var records = [Record]()
#Published var results = [Result]()
let recordProcessor = RecordProcessor()
var cancellables = Set<AnyCancellable>()
init() {
$records
.map(recordProcessor.process)
.switchToLatest()
.sink {
self.results = $0
}
.store(in: &cancellables)
}
}
class RecordProcessor: ObservableObject {
func process(items: [Record]) -> AnyPublisher<[Result], Never> {
return Just([]).eraseToAnyPublisher()
}
}
struct RootView: View {
var body: some View {
MyView()
.environmentObject(Model())
}
}
struct MyView: View {
#EnvironmentObject var model: Model
var body: some View {
ForEach(model.results) { }
}
}

Related

How to observer a property in swift ui

How to observe property value in SwiftUI.
I know some basic publisher and observer patterns. But here is a scenario i am not able to implement.
class ScanedDevice: NSObject, Identifiable {
//some variables
var currentStatusText: String = "Pending"
}
here CurrentStatusText is changed by some other callback method that update the status.
Here there is Model class i am using
class SampleModel: ObservableObject{
#Published var devicesToUpdated : [ScanedDevice] = []
}
swiftui component:
struct ReviewView: View {
#ObservedObject var model: SampleModel
var body: some View {
ForEach(model.devicesToUpdated){ device in
Text(device.currentStatusText)
}
}
}
Here in UI I want to see the real-time status
I tried using publisher inside ScanDevice class but sure can to use it in 2 layer
You can observe your class ScanedDevice, however you need to manually use a objectWillChange.send(),
to action the observable change, as shown in this example code.
class ScanedDevice: NSObject, Identifiable {
var name: String = "some name"
var currentStatusText: String = "Pending"
init(name: String) {
self.name = name
}
}
class SampleViewModel: ObservableObject{
#Published var devicesToUpdated: [ScanedDevice] = []
}
struct ReviewView: View {
#ObservedObject var viewmodel: SampleViewModel
var body: some View {
VStack (spacing: 33) {
ForEach(viewmodel.devicesToUpdated){ device in
HStack {
Text(device.name)
Text(device.currentStatusText).foregroundColor(.red)
}
Button("Change \(device.name)") {
viewmodel.objectWillChange.send() // <--- here
device.currentStatusText = UUID().uuidString
}.buttonStyle(.bordered)
}
}
}
}
struct ContentView: View {
#StateObject var viewmodel = SampleViewModel()
var body: some View {
ReviewView(viewmodel: viewmodel)
.onAppear {
viewmodel.devicesToUpdated = [ScanedDevice(name: "device-1"), ScanedDevice(name: "device-2")]
}
}
}

How to trigger automatic SwiftUI Updates with #ObservedObject using MVVM

I have a question regarding the combination of SwiftUI and MVVM.
Before we start, I have read some posts discussing whether the combination of SwiftUI and MVVM is necessary. But I don't want to discuss this here, as it has been covered elsewhere. I just want to know if it is possible and, if yes, how. :)
So here comes the code. I tried to add the ViewModel Layer in between the updated Object class that contains a number that should be updated when a button is pressed. The problem is that as soon as I put the ViewModel Layer in between, the UI does not automatically update when the button is pressed.
View:
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#ObservedObject var numberStorage = NumberStorage()
var body: some View {
VStack {
// Text("\(viewModel.getNumberObject().number)")
// .padding()
// Button("IncreaseNumber") {
// viewModel.increaseNumber()
// }
Text("\(numberStorage.getNumberObject().number)")
.padding()
Button("IncreaseNumber") {
numberStorage.increaseNumber()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
ViewModel:
class ViewModel: ObservableObject {
#Published var number: NumberStorage
init() {
self.number = NumberStorage()
}
func increaseNumber() {
self.number.increaseNumber()
}
func getNumberObject() -> NumberObject {
self.number.getNumberObject()
}
}
Model:
class NumberStorage:ObservableObject {
#Published var numberObject: NumberObject
init() {
numberObject = NumberObject()
}
public func getNumberObject() -> NumberObject {
return self.numberObject
}
public func increaseNumber() {
self.numberObject.number+=1
}
}
struct NumberObject: Identifiable {
let id = UUID()
var number = 0
} ```
Looking forward to your feedback!
I think your code is breaking MVVM, as you're exposing to the view a storage model. In MVVM, your ViewModel should hold only two things:
Values that your view should display. These values should be automatically updated using a binding system (in your case, Combine)
Events that the view may produce (in your case, a button tap)
Having that in mind, your ViewModel should wrap, adapt and encapsulate your model. We don't want model changes to affect the view. This is a clean approach that does that:
View:
struct ContentView: View {
#StateObject // When the view creates the object, it must be a state object, or else it'll be recreated every time the view is recreated
private var viewModel = ViewModel()
var body: some View {
VStack {
Text("\(viewModel.currentNumber)") // We don't want to use functions here, as that will create a new object , as SwiftUI needs the same reference in order to keep track of changes
.padding()
Button("IncreaseNumber") {
viewModel.increaseNumber()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
ViewModel:
class ViewModel: ObservableObject {
#Published
private(set) var currentNumber: Int = 0 // Private set indicates this should only be mutated by the viewmodel
private let numberStorage = NumberStorage()
init() {
numberStorage.currentNumber
.map { $0.number }
.assign(to: &$currentNumber) // Here we're binding the current number on the storage to the published var that the view is listening to.`&$` basically assigns it to the publishers address
}
func increaseNumber() {
self.numberStorage.increaseNumber()
}
}
Model:
class NumberStorage {
private let currentNumberSubject = CurrentValueSubject<NumberObject, Never>(NumberObject())
var currentNumber: AnyPublisher<NumberObject, Never> {
currentNumberSubject.eraseToAnyPublisher()
}
func increaseNumber() {
let currentNumber = currentNumberSubject.value.number
currentNumberSubject.send(.init(number: currentNumber + 1))
}
}
struct NumberObject: Identifiable { // I'd not use this, just send and int directly
let id = UUID()
var number = 0
}
It's a known problem. Nested observable objects are not supported yet in SwiftUI. I don't think you need ViewModel+Model here since ViewModel seems to be enough.
To make this work you have to trigger objectWillChange of your viewModel manually when objectWillChange of your model is triggered:
class ViewModel: ObservableObject {
init() {
number.objectWillChange.sink { [weak self] (_) in
self?.objectWillChange.send()
}.store(in: &cancellables)
}
}
You better listen to only the object you care not the whole observable class if it is not needed.
Plus:
Since instead of injecting, you initialize your viewModel in your view, you better use StateObject instead of ObservedObject. See the reference from Apple docs: Managing model data in your app
One way you could handle this is to observe the publishers in your Storage class and send the objectWillChange publisher when it changes. I have done this in personal projects by adding a class that all my view models inherit from which provides a nice interface and handles the Combine stuff like this:
Parent ViewModel
import Combine
class ViewModel: ObservableObject {
private var cancellables: Set<AnyCancellable> = []
func publish<T>(on publisher: Published<T>.Publisher) {
publisher.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
}
}
Specific ViewModel
class ContentViewModel: ViewModel {
private let numberStorage = NumberStorage()
var number: Int { numberStorage.numberObject.number }
override init() {
super.init()
publish(on: numberStorage.$numberObject)
}
func increaseNumber() {
numberStorage.increaseNumber()
}
}
View
struct ContentView: View {
#StateObject var viewModel = ContentViewModel()
var body: some View {
VStack {
Text("\(viewModel.number)")
.padding()
Button("IncreaseNumber") {
viewModel.increaseNumber()
}
}
}
}
Model/Storage
class NumberStorage:ObservableObject {
#Published var numberObject: NumberObject
init() {
numberObject = NumberObject()
}
public func increaseNumber() {
self.numberObject.number += 1
}
}
struct NumberObject: Identifiable {
let id = UUID()
var number = 0
}
This results in the view re-rendering any time Storage.numberObject changes.

SwiftUI+Combine - Dynamicaly subscribing to a dict of publishers

In my project i hold a large dict of items that are updated via grpc stream. Inside the app there are several places i am rendering these items to UI and i would like to propagate the realtime updates.
Simplified code:
struct Item: Identifiable {
var id:String = UUID().uuidString
var name:String
var someKey:String
init(name:String){
self.name=name
}
}
class DataRepository {
public var serverSymbols: [String: CurrentValueSubject<Item, Never>] = [:]
// method that populates the dict
func getServerSymbols(serverID:Int){
someService.fetchServerSymbols(serverID: serverID){ response in
response.data.forEach { (name,sym) in
self.serverSymbols[name] = CurrentValueSubject(Item(sym))
}
}
}
// background stream that updates the values
func serverStream(symbols:[String] = []){
someService.initStream(){ update in
DispatchQueue.main.async {
self.serverSymbols[data.id]?.value.someKey = data.someKey
}
}
}
}
ViewModel:
class SampleViewModel: ObservableObject {
#Injected var repo:DataRepository // injection via Resolver
// hardcoded value here for simplicity (otherwise dynamically added/removed by user)
#Published private(set) var favorites:[String] = ["item1","item2"]
func getItem(item:String) -> Item {
guard let item = repo.serverSymbols[item] else { return Item(name:"N/A")}
return ItemPublisher(item: item).data
}
}
class ItemPublisher: ObservableObject {
#Published var data:Item = Item(name:"")
private var cancellables = Set<AnyCancellable>()
init(item:CurrentValueSubject<Item, Never>){
item
.receive(on: DispatchQueue.main)
.assignNoRetain(to: \.data, on: self)
.store(in: &cancellables)
}
}
Main View with subviews:
struct FavoritesView: View {
#ObservedObject var viewModel: QuotesViewModel = Resolver.resolve()
var body: some View {
VStack {
ForEach(viewModel.favorites, id: \.self) { item in
FavoriteCardView(item: viewModel.getItem(item: item))
}
}
}
}
struct FavoriteCardView: View {
var item:Item
var body: some View {
VStack {
Text(item.name)
Text(item.someKey) // dynamic value that should receive the updates
}
}
}
I must've clearly missed something or it's a completely wrong approach, however my Item cards do not receive any updates (i verified the backend stream is active and serverSymbols dict is getting updated). Any advice would be appreciated!
I've realised i've made a mistake - in order to receive the updates i need to pass down the ItemPublisher itself. (i was incorrectly returning ItemPublisher.data from my viewModel's method)
I've refactored the code and make the ItemPublisher provide the data directly from my repository using the item key, so now each card is subscribing individualy using the publisher.
Final working code now:
class SampleViewModel: ObservableObject {
// hardcoded value here for simplicity (otherwise dynamically added/removed by user)
#Published private(set) var favorites:[String] = ["item1","item2"]
}
MainView and CardView:
struct FavoritesView: View {
#ObservedObject var viewModel: QuotesViewModel = Resolver.resolve()
var body: some View {
VStack {
ForEach(viewModel.favorites, id: \.self) { item in
FavoriteCardView(item)
}
}
}
}
struct FavoriteCardView: View {
var itemName:String
#ObservedObject var item:ItemPublisher
init(_ itemName:String){
self.itemName = itemName
self.item = ItemPublisher(item:item)
}
var body: some View {
let itemData = item.data
VStack {
Text(itemData.name)
Text(itemData.someKey)
}
}
}
and lastly, modified ItemPublisher:
class ItemPublisher: ObservableObject {
#Injected var repo:DataRepository
#Published var data:Item = Item(name:"")
private var cancellables = Set<AnyCancellable>()
init(item:String){
self.data = Item(name:item)
if let item = repo.serverSymbols[item] {
self.data = item.value
item.receive(on: DispatchQueue.main)
.assignNoRetain(to: \.data, on: self)
.store(in: &cancellables)
}
}
}

Swift Combine Not Receiving Published Values

I am working on a project that is using Combine to get updates from Firebase Firestore. I have a StockListView, a StockListCellView, and a StockDetailView that I want updates to be registered in.
The StockListView holds StockListCellViews which push StockDetailsViews onto the stack. Each view also has a corresponding ViewModel where I am working with Combine.
My trouble is my StockDetailView is not receiving the updates from Combine and I can't see why. Below is a simplified version of the code for each view and viewModel. I think this has something to do with how I am assigning in the StockDetailViewModel but I can't figure it out. Any help would be appreciated.
StockListViewModel - Works Great
class StockListViewModel: ObservableObject {
#Published var stockRepository = StockRepository()
#Published var stockListCellViewModels = [StockListCellViewModel]()
private var cancellables = Set<AnyCancellable>()
init() {
stockRepository.$stocks.map { stocks in
stocks.map { stock in
StockListCellViewModel(stockDetailViewModel: StockDetailViewModel(stock: stock))
}
}
.assign(to: \.stockListCellViewModels, on: self)
.store(in: &cancellables)
}
}
StockListView - Works Great
struct StockListView: View {
#ObservedObject var stockRepository = StockRepository()
#ObservedObject var stockListVM = StockListViewModel()
var body: some View {
NavigationView {
List {
ForEach(stockListVM.stockListCellViewModels) { stockListCellVM in
NavigationLink(destination: StockDetailView(stockDetailVM: stockListCellVM.stockDetailViewModel)) {
StockListCell(stockListVM: stockListVM, stockListCellVM: stockListCellVM)
}
}
} // List
.listStyle(PlainListStyle())
.navigationBarTitle("stock")
} // NavigationView
} // View
}
StockListCellViewModel - Works Great
class StockListCellViewModel: ObservableObject, Identifiable {
var id: String = ""
#Published var stockDetailViewModel: StockDetailViewModel
#Published var stock: Stock
private var cancellables = Set<AnyCancellable>()
init(stockDetailViewModel: StockDetailViewModel) {
self.stockDetailViewModel = stockDetailViewModel
self.stock = stockDetailViewModel.stock
stockDetailViewModel.$stock.compactMap { stock in
stock.id
}
.assign(to: \.id, on: self)
.store(in: &cancellables)
stockDetailViewModel.$stock.map { stock in
StockDetailViewModel(stock: stock)
}
.assign(to: \.stockDetailViewModel, on: self)
.store(in: &cancellables)
}
}
StockListCellView - Works Great
struct StockListCell: View {
#ObservedObject var stockListVM: StockListViewModel
#ObservedObject var stockListCellVM: StockListCellViewModel
var body: some View {
Text(stockListCellVM.stock.ticker)
}
}
StockDetailViewModel - Not Updating
class StockDetailViewModel: ObservableObject, Identifiable {
var id: String = ""
#Published var stock: Stock
private var cancellables = Set<AnyCancellable>()
init(stock: Stock) {
self.stock = stock
self.chartColor = UIColor()
$stock.compactMap { stock in
stock.id
}
.assign(to: \.id, on: self)
.store(in: &cancellables)
}
StockDetailView - Not Updating
struct StockDetailView: View {
#ObservedObject var stockDetailVM: StockDetailViewModel
var body: some View {
Text(stockDetailVM.stock.ticker)
}
}
This took me the better part of two weeks to solve and I wanted to post it here for anyone experiencing the same issue I had. I rebuilt a simple example to make things easier to understand. Here is what the app does.
Connects to a Firestore database and starts a SnapShootListener to collect Note objects.
Once collected it stores the Notes, in an array of Note objects.
This array of notes is built into a List of notes.
Each note progresses to a NoteDetailView.
What I expected to happen was that as a Note was updated on the server, I would see the note update in real-time in the List and in the DetailView. My problem was that I could only see live updates in the List.
The reason for this is because you cannot bind a data element from a ForEach loop. This means that any updates made on the server never make it to the DetailView.
To solve this I introduced EnvironmentObjects and created DetailViews by passing the index of the Note to be shown in the DetailView to the DetailView. This allowed me to access the correct Note in the DetailView. The code below shows how this was done. NOTE: I did not added a NoteDetailViewModel.swift to simplify things.
<YOUR_PROJECT_NAME>App.swift
#main
struct ToDoSwiftUITutorialApp: App {
// 1. An #StateObject was created for the NoteRepository connected to Firestore.
#StateObject private var noteRepository = NoteRepository()
// Firebase
init() {
FirebaseApp.configure()
Auth.auth().signInAnonymously()
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(noteRepository) // noteRepository was added to ContentView as an EnvironmentObject.
}
}
}
NoteRepository.swift
class NoteRepository: ObservableObject {
let db = Firestore.firestore()
// Publishing the array of Note object received from Firestore.
#Published var notes = [Note]()
init() {
loadData()
}
func loadData() {
db.collection("notes").addSnapshotListener { (querySnapshot, error) in
if let querySnapshot = querySnapshot {
self.notes = querySnapshot.documents.compactMap { document in
do {
let x = try document.data(as: Note.self)
return x
}
catch {
print(error)
}
return nil
}
}
}
}
}
NoteListView.swift
struct NoteListView: View {
#ObservedObject var noteListViewModel = NoteListViewModel()
var body: some View {
NavigationView {
List {
ForEach(noteListViewModel.noteCellViewModels.indices, id: \.self) { index in
NavigationLink(destination: NoteDetailView(index: index).environmentObject(self.noteListViewModel)) {
NoteCellView(noteCellViewModel: noteListViewModel.noteCellViewModels[index])
} // NavigationLink
} // ForEach
}// List
.navigationBarTitle("Notes")
} // NavigationView
}
}
NoteListViewModel.swift
class NoteListViewModel: ObservableObject {
#Published var noteRepository = NoteRepository()
#Published var noteCellViewModels = [NoteCellViewModel]()
private var cancellables = Set<AnyCancellable>()
init() {
noteRepository.$notes.map { notes in
notes.map { note in
NoteCellViewModel(note: note)
}
}
.assign(to: \.noteCellViewModels, on: self)
.store(in: &cancellables)
}
}
NoteCellView.swift
struct NoteCellView: View {
#ObservedObject var noteCellViewModel: NoteCellViewModel
var body: some View {
Text(noteCellViewModel.note.note)
}
}
NoteCellViewModel.swift
class NoteCellViewModel: ObservableObject, Identifiable {
var id: String = ""
#Published var note: Note
private var cancellables = Set<AnyCancellable>()
init(note: Note) {
self.note = note
$note.compactMap { note in
note.id
}
.assign(to: \.id, on: self)
.store(in: &cancellables)
}
}
NoteDetailView.swift
struct NoteDetailView: View {
#EnvironmentObject var noteRepository: NoteRepository
var index: Int
var body: some View {
Text(noteRepository.notes[index].note)
}
}

How to use Bind an Associative Swift enum?

I have a GroupView that accepts a binding as a parameter because I want the GroupView to modify the data in the enum.
Can some help me on how to accomplish this?
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
GroupView(group: /* What do i put here? */) // <----------------
}
}
}
struct GroupView: View {
#Binding var group: Group
var body: some View {
Text("Hello World")
}
}
class ViewModel : ObservableObject {
#Published var instruction: Instruction!
init() {
instruction = .group(Group(groupTitle: "A Group struct"))
}
}
enum Instruction {
case group(Group)
}
struct Group { var groupTitle: String }
Well, this certainly will work... but probably there's a better approach to your problem. But no one is in a better position than you, to determine that. So I'll just answer your question about how to pass a binding.
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
GroupView(group: viewModel.groupBinding)
}
}
}
class ViewModel : ObservableObject {
#Published var instruction: Instruction!
init() {
instruction = .group(Group(groupTitle: "A Group struct"))
}
var groupBinding: Binding<Group> {
return Binding<Group>(get: {
if case .group(let g) = self.instruction {
return g
} else {
return Group(groupTitle: "")
}
}, set: {
self.instruction = .group($0)
})
}
}