7

After scouring Apple's SwiftUI docs, the web and stackoverflow, I can't seem to figure out how to use multiple classes and passing EnviromentObject data between them. All the articles on SwiftUI's EnvironmentObject, ObservableObjects, Bindings show how to pass data from a model to a view, but how about from one model to another. Or am I thinking about this the wrong way altogether.

How can I pass a @Published var from Class A to Class B?

In this simple SwiftUI example app I am trying to pass @Published data from a Settings class to a NetworkManager class. This app has a single ContentView with two form fields for username and password with a button that when pressed will contact an API and display the results.

The below code will crash because the NetworkManager mockService function does not know what "settings" is. I can read the "Settings" observed EnvironmentObject in a View, but how do I get that data in another class? I am guessing there is something to do with Bindings here, but not sure what to do.

SceneDelegate:

...
var settings = Settings()
var networkManager = NetworkManager()

...
let contentView = ContentView()
    .environmentObject(settings)
    .environmentObject(networkManager)
...

ContentView.swift

class Settings: ObservableObject {
    @Published var username: String = ""
    @Published var password: String = ""    
}

// This function in reality will be rewritten and expanded with multiple networking calls
class NetworkManager: ObservableObject {
    @Published var token: String = ""

    func mockService() {
        token = settings.username + settings.password
    }
}

struct ContentView: View {
    @EnvironmentObject var settings: Settings
    @EnvironmentObject var networkManager: NetworkManager
    var body: some View {
        VStack {
            TextField("Username", text: $settings.username)
            TextField("Password", text: $settings.password)
            Button("Update"){
                self.networkManager.mockService()
            }
            Divider()
            Text("\(networkManager.token)")

        }
    }
}
Ryan
  • 10,798
  • 11
  • 46
  • 60
  • I'm very dubious about the idea of passing data between models. Models ought to be "sources of truth" which are probably some level of persistent (forever, during a session). They ought not be where calculations are performed on behalf of the business logic, save for unit conversion or some such. Their data ought to be strictly manipulated by controllers. – Hack Saw Dec 12 '19 at 07:17
  • 1
    Good point. So if we call the NetworkManager class a controller instead of a model how do we pass data from one model to another controller in SwiftUI? – Ryan Dec 12 '19 at 07:29
  • @Ryan Did you find a solution to this, having identical issue with a SettingsManager and NetworkManager – Brett Feb 06 '20 at 23:41
  • 1
    @Brett, we ended up using UserDefaults. Not the best place for storing username, password and API key but we are looking at Keychain or encrypting the information before storing. There was some code we found that will write to UserDefaults any time we update the Settings class. – Ryan Feb 08 '20 at 02:13
  • Yeah I wouldn't store that stuff in UserDefaults, store it in Keychain, there are some wrappers around if you don't want to write that stuff yourself. We ended up just passing in the UserSettings and KeychainManager(Both classes) to the NetworkManager in SceneDelegate as we realised that the NetworkManager is pulling this data and doesn't need to be an observer on them. Thanks for the reply @Ryan – Brett Feb 10 '20 at 01:00

2 Answers2

0

This's one way of making the network object aware of settings object, but i don't think that it's the best way to do it, you can try it:

class Settings: ObservableObject {
    @Published var username: String = ""
    @Published var password: String = ""
    var currentSettingsPublisher: PassthroughSubject<Settings,Never> = .init()

    var cancellablesBag: Set<AnyCancellable> = []


    init() {
        observeChanges()
    }

    // Object will change is called to notify subscribers about changes( That's how swiftUI react to changes)
    private func observeChanges() {

        self.objectWillChange.sink { [weak self] (_) in
            guard let self = self else { return }
            self.currentSettingsPublisher.send(self)
        }.store(in: &cancellablesBag)
    }
}

// This function in reality will be rewritten and expanded with multiple networking calls
class NetworkManager: ObservableObject {
    
    @Published var token: String = ""
    var cancellablesBag: Set<AnyCancellable> = []
    var currentSettingsPublisher: AnyPublisher<Settings,Never>
    private var settings: Settings?

    init(_ settings: AnyPublisher<Settings,Never>) {
        self.currentSettingsPublisher = settings
        observeSettings()
    }

    func observeSettings() {
        currentSettingsPublisher
            // .debounce(for: .seconds(0.5), scheduler: RunLoop.main) maybe you can use debounce to debounce updating of your settings object
            .sink { [weak self] (newSettings) in
            guard let self = self else { return }
                print("i have the new settings")
            self.settings = newSettings
        }.store(in: &cancellablesBag)
    }
    

    
    func mockService() {
        guard let settings = settings else {
            return assertionFailure("Settings is nil, what to do ?")
        }

        token = settings.username + settings.password
        print("new token: \(token)")
    }
}

struct ContentView: View {
    @EnvironmentObject var settings: Settings
    @EnvironmentObject var networkManager: NetworkManager
    var body: some View {
        VStack {
            TextField("Username", text: $settings.username)
            TextField("Password", text: $settings.password)
            Button("Update"){
                self.networkManager.mockService()
            }
            Divider()
            Text("\(networkManager.token)")

        }
    }
}

and for SceneDelegate

var settings = Settings()
var networkManager: NetworkManager!

        func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

networkManager = 
    NetworkManager(settings.currentSettingsPublisher.eraseToAnyPublisher())
            
    
            let contentView = ContentView()
                .environmentObject(settings)
                .environmentObject(networkManager)

}
Mostfa Essam
  • 745
  • 8
  • 11
0

You can use .sink to observe changes from @Published properties

Code Example:

import Foundation
import Combine

class ItemList:ObservableObject {
    @Published var items: [Item] = []
}

struct Item {
    let name:String
    let image:String
}

class viewModel {
    var itemsCancellable:AnyCancellable
    init(il:ItemList){
        self.itemsCancellable = il.$items.sink { items in
        print("items array changed")
        }
    }
}

If you store your data inside a singleton class, then you can easily read from viewModel and sink changes:

import Foundation
import Combine

class ItemList:ObservableObject {

    static var shared:ItemList = ItemList()
    @Published var items: [Item] = []
}

struct Item {
    let name:String
    let image:String
}

class viewModel {
    var itemsCancellable = ItemList.shared.$items.sink { items in
        print("items array changed")
    }
}
caglar
  • 279
  • 2
  • 5