I am working with SwiftUI and am using MVVM with my VM acting as my EnvironmentObjects.
I first create a AuthSession environment object which has a string for currentUserId stored.
I then create another environment object for Offers that is trying to include the AuthSession environment object so I can can filter results pulled from a database with Combine. When I add an #EnvironmentObject property to the Offer view model I get an error stating that AuthSession is not passed. This makes sense since it's not a view.
My question is, is it best to sort the results in the view or is there a way to add an EnvironmentObject to another EnvironmentObject? I know there is an answer here, but this model answer is not using VM as the EOs.
App File
#main
struct The_ExchangeApp: App {
// #EnvironmentObjects
#StateObject private var authListener = AuthSession()
#StateObject private var offerHistoryViewModel = OfferHistoryViewModel(offerRepository: OfferRepository())
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(authListener)
.environmentObject(offerHistoryViewModel)
}
}
}
AuthSession.swift
class AuthSession: ObservableObject {
#Published var currentUser: User?
#Published var loggedIn = false
#Published var currentUserUid = ""
// Intitalizer
init() {
self.getCurrentUserUid()
}
}
OfferHistoryViewModel.swift - The error is called just after the .filter in startCombine().
class OfferHistoryViewModel: ObservableObject {
// MARK: ++++++++++++++++++++++++++++++++++++++ Properties ++++++++++++++++++++++++++++++++++++++
// Access to AuthSession for filtering offer made by the current user.
#EnvironmentObject var authSession: AuthSession
// Properties
var offerRepository: OfferRepository
// Published Properties
#Published var offerRowViewModels = [OfferRowViewModel]()
// Combine Cancellable
private var cancellables = Set<AnyCancellable>()
// MARK: ++++++++++++++++++++++++++++++++++++++ Methods ++++++++++++++++++++++++++++++++++++++
// Intitalizer
init(offerRepository: OfferRepository) {
self.offerRepository = offerRepository
self.startCombine()
}
// Starting Combine - Filter results for offers created by the current user only.
func startCombine() {
offerRepository
.$offers
.receive(on: RunLoop.main)
.map { offers in
offers
.filter { offer in
(self.authSession.currentUserUid != "" ? offer.userId == self.authSession.currentUserUid : false) // ERROR IS CALLED HERE
}
.map { offer in
OfferRowViewModel(offer: offer)
}
}
.assign(to: \.offerRowViewModels, on: self)
.store(in: &cancellables)
}
}
Error
Thread 1: Fatal error: No ObservableObject of type AuthSession found. A View.environmentObject(_:) for AuthSession may be missing as an ancestor of this view.
I solved this by passing currentUserUid from AuthSession from my view to the view model. The view model changes to the following.
class OfferHistoryViewModel: ObservableObject {
// MARK: ++++++++++++++++++++++++++++++++++++++ Properties ++++++++++++++++++++++++++++++++++++++
var offerRepository: OfferRepository
// Published Properties
#Published var offerRowViewModels = [OfferRowViewModel]()
// Combine Cancellable
private var cancellables = Set<AnyCancellable>()
// MARK: ++++++++++++++++++++++++++++++++++++++ Methods ++++++++++++++++++++++++++++++++++++++
// Intitalizer
init(offerRepository: OfferRepository) {
self.offerRepository = offerRepository
}
// Starting Combine - Filter results for offers created by the current user only.
func startCombine(currentUserUid: String) {
offerRepository
.$offers
.receive(on: RunLoop.main)
.map { offers in
offers
.filter { offer in
(currentUserUid != "" ? offer.userId == currentUserUid : false)
}
.map { offer in
OfferRowViewModel(offer: offer)
}
}
.assign(to: \.offerRowViewModels, on: self)
.store(in: &cancellables)
}
}
Then in the view I pass the currentUserUid in onAppear.
struct OfferHistoryView: View {
// MARK: ++++++++++++++++++++++++++++++++++++++ Properties ++++++++++++++++++++++++++++++++++++++
#EnvironmentObject var authSession: AuthSession
#EnvironmentObject var offerHistoryViewModel: OfferHistoryViewModel
// MARK: ++++++++++++++++++++++++++++++++++++++ View ++++++++++++++++++++++++++++++++++++++
var body: some View {
// BuildView
} // View
.onAppear(perform: {
self.offerHistoryViewModel.startCombine(currentUserUid: self.authSession.currentUserUid)
})
}
}
This works well for me and I hope it helps someone else.
Related
I want to create a global variable for showing a loadingView, I tried lots of different ways but could not figure out how to. I need to be able to access this variable across the entire application and update the MotherView file when I change the boolean for the singleton.
struct MotherView: View {
#StateObject var viewRouter = ViewRouter()
var body: some View {
if isLoading { //isLoading needs to be on a singleton instance
Loading()
}
switch viewRouter.currentPage {
case .page1:
ContentView()
case .page2:
PostList()
}
}
}
struct MotherView_Previews: PreviewProvider {
static var previews: some View {
MotherView(viewRouter: ViewRouter())
}
}
I have tried the below singleton but it does not let me update the shared instance? How do I update a singleton instance?
struct LoadingSingleton {
static let shared = LoadingSingleton()
var isLoading = false
private init() { }
}
Make your singleton a ObservableObject with #Published properties:
struct ContentView: View {
#StateObject var loading = LoadingSingleton.shared
var body: some View {
if loading.isLoading {
Text("Loading...")
}
ChildView()
Button(action: { loading.isLoading.toggle() }) {
Text("Toggle loading")
}
}
}
struct ChildView : View {
#StateObject var loading = LoadingSingleton.shared
var body: some View {
if loading.isLoading {
Text("Child is loading")
}
}
}
class LoadingSingleton : ObservableObject {
static let shared = LoadingSingleton()
#Published var isLoading = false
private init() { }
}
I should mention that in SwiftUI, it's common to use .environmentObject to pass a dependency through the view hierarchy rather than using a singleton -- it might be worth looking into.
First, make LoadingSingleton a class that adheres to the ObservableObject protocol. Use the #Published property wrapper on isLoading so that your SwiftUI views update when it's changed.
class LoadingSingleton: ObservableObject {
#Published var isLoading = false
}
Then, put LoadingSingleton in your SceneDelegate and hook it into your SwiftUI views via environmentObject():
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
static let singleton = LoadingSingleton()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(SceneDelegate.singleton))
self.window = window
window.makeKeyAndVisible()
}
}
}
To enable your SwiftUI views to update when changing isLoading, declare a variable in the view's struct, like this:
struct MyView: View {
#EnvironmentObject var singleton: LoadingSingleton
var body: some View {
//Do something with singleton.isLoading
}
}
When you want to change the value of isLoading, just access it via SceneDelegate.singleton.isLoading, or, inside a SwiftUI view, via singleton.isLoading.
I am working with SwiftUI and #EnvironmentObjects. I am using the SwiftUI App Lifecycle. In this file I create a #StateObjects for ListingRepository and attach it to ContentView() with .envrionmentObjects().
struct MyApp: App {
// #EnvironmentObjects
#StateObject private var listingRepository = ListingRepository()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(listingRepository)
}
}
}
My assumption was that I could now access listingRepository by using an #EnvironmentObject wrapper. However, it seems I must instantiate this again. Below is my ListingViewModel. My first attempt looked like the following.
class MarketplaceViewModel: ObservableObject {
#EnvironmentObject var listingRepository: ListingRepository
#Published var listingRowViewModels = [ListingRowViewModel]()
private var cancellables = Set<AnyCancellable>()
init() {
listingRepository
.$listings
.receive(on: RunLoop.main)
.map { listings in
listings.map { listing in
ListingRowViewModel(listing: listing)
}
}
.assign(to: \.listingRowViewModels, on: self)
.store(in: &cancellables)
}
}
This threw the following error.
Fatal error: No ObservableObject of type ListingRepository found. A
View.environmentObject(_:) for ListingRepository may be missing as an
ancestor of this view.
The second option uses #Published and fixes the error.
class MarketplaceViewModel: ObservableObject {
#Published var listingRepository = ListingRepository()
#Published var listingRowViewModels = [ListingRowViewModel]()
private var cancellables = Set<AnyCancellable>()
init() {
listingRepository
.$listings
.receive(on: RunLoop.main)
.map { listings in
listings.map { listing in
ListingRowViewModel(listing: listing)
}
}
.assign(to: \.listingRowViewModels, on: self)
.store(in: &cancellables)
}
}
My questions are the following.
Is it necessary to instantiate twice like I am?
Why doesn't the instantiation in #main App work?
in your #main App you should define your environment object like this:
struct MyApp: App {
// #EnvironmentObjects
var listingRepository = ListingRepository()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(listingRepository)
}
}
}
Then it suffices to have the ListingRepository define as:
class ListingRepository: ObservableObject {
#Published var ...
#Published var ...
// Your code here
}
Don't redeclare the ListingRepository as an EnvironmentObject in your MarketPlaceViewModel.
One note, however, it is not wise to make your ListingRepository an observable object and access this object from your views through the MarketPlaceViewModel. If the MarketPlaceViewModel is used to fill the views in your app and the MarketPlaceViewModel gets the data from the ListingRepository, you should make your MarketPlaceViewModel the EnvironmentObject and not the ListingRepository.
The thing in SwiftUI is the you want the EnvironmentObject to publish changes to several views in your app, so that these views can reconstruct themselves. If you use a view model between your model and your view, the view model should be the EnvironmentObject.
This is what I am trying to achieve:
class MyVC: UIViewController {
#State var myBoolState: Bool = false
private var subscribers = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
myBoolState.sink { value in .... }.store(in:&subscribers)
}
func createTheView() {
let vc = UIHostingController(rootView: MySwiftUIView(myBoolState: $myBoolState))
self.navigationController!.pushViewController(vc, animated: true)
}
}
struct MySwiftUIView: View {
#Binding var myBoolState: Bool
var body: some View {
Button(action: {
myBoolState = true
}) {
Text("Push Me")
}
}
}
But the above of course does not compile.
So the question is: can I somehow declare a published property inside a view controller, pass it to a SwiftUI View and get notified when the SwiftUI view changes its value?
The #State wrapper works (by design) only inside SwiftUI view, so you cannot use it in view controller. Instead there is ObsevableObject/ObservedObject pattern for such purpose because it is based on reference types.
Here is a demo of possible solution for your scenario:
import Combine
class ViewModel: ObservableObject {
#Published var myBoolState: Bool = false
}
class MyVC: UIViewController {
let vm = ViewModel()
private var subscribers = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
vm.$myBoolState.sink { value in
print(">> here it goes")
}.store(in:&subscribers)
}
func createTheView() {
let vc = UIHostingController(rootView: MySwiftUIView(vm: self.vm))
self.navigationController!.pushViewController(vc, animated: true)
}
}
struct MySwiftUIView: View {
#ObservedObject var vm: ViewModel
var body: some View {
Button(action: {
vm.myBoolState = true
}) {
Text("Push Me")
}
}
}
I use Combine in viewModels to update the views. But if I store the AnyCancellable objects into a set of AnyCancellable, the deinit method is never called. I use the deinit to cancel all cancellables objects.
struct View1: View {
#ObservedObject var viewModel:ViewTextModel = ViewTextModel()
#Injected var appActions:AppActions
var body: some View {
VStack {
Text(self.viewModel.viewText)
Button(action: {
self.appActions.goToView2()
}) {
Text("Go to view \(self.viewModel.viewText)")
}
}
}
}
class ViewTextModel: ObservableObject {
#Published var viewText: String
private var cancellables = Set<AnyCancellable>()
init(state:AppState) {
// initial state
viewText = "view \(state.view)"
// updated state
state.$view.removeDuplicates().map{ "view \($0)"}.assign(to: \.viewText, on: self).store(in: &cancellables)
}
deinit {
cancellables.forEach { $0.cancel() }
}
}
Each time the view is rebuilt, a new viewmodel is instantiated but the old one is not destroyed. viewText attribute is updated on each instance with state.$view.removeDuplicates().map{ "view \($0)"}.assign(to: \.viewText, on: self).store(in: &cancellables)
If I don't store the cancellable object in the set, deinit is called but viewText is not updated if the state's changed for the current view.
Do you have an idea of how to manage the update of the state without multiplying the instances of the viewmodel ?
Thanks
You could use sink instead of assign:
state.$view
.removeDuplicates()
.sink { [weak self] in self?.viewText = $0 }
.store(in: &cancellables)
But I question the need for Combine here at all. Just use a computed property:
class ViewTextModel: ObservableObject {
#Published var state: AppState
var viewText: String { "view \(state.view)" }
}
UPDATE
If your deployment target is iOS 14 (or macOS 11) or later:
Because you are storing to an #Published, you can use the assign(to:) operator instead. It manages the subscription for you without returning an AnyCancellable.
state.$view
.removeDuplicates()
.map { "view \($0)" }
.assign(to: &$viewText)
// returns Void, so nothing to store
I can't update EnvironmentObject with data from network response. In view, initial data are displayed properly. I want class calling to API to update global state with response. Then I got the crash
Fatal error: No ObservableObject of type AppState found.
A View.environmentObject(_:) for AppState may be missing as an ancestor of this view.: file /BuildRoot/Library/Caches/com.apple.xbs/Sources/Monoceros_Sim/Monoceros-30.4/Core/EnvironmentObject.swift, line 55#
struct GameView: View {
var location = LocationService()
#EnvironmentObject var appState: AppState
var body: some View {
VStack{
Text("countryRegion: \(self.appState.countryRegion)")
Text("adminDistrict: \(self.appState.adminDistrict)")
}.onAppear(perform: startMonitoring)
}
func startMonitoring() {
self.appState.isGameActive = true
self.location.startMonitoringLocation()
}
}
class LocationService: NSObject, CLLocationManagerDelegate{
#EnvironmentObject var appState: AppState
...
func getAddress(longitude: CLLocationDegrees, latitude: CLLocationDegrees) {
let url = URL(string: "http://dev.virtualearth.net/REST/v1/Locations/\(latitude),\(longitude)?o=json&key=\(self.key)")!
URLSession.shared.dataTask(with: url) { (data, response, error) in
guard let data = data else { return }
let response = try! JSONDecoder().decode(Results.self, from: data)
DispatchQueue.main.async {
self.appState.adminDistrict = response.resourceSets[0].resources[0].address.adminDistrict;
self.appState.countryRegion = response.resourceSets[0].resources[0].address.countryRegion;
}
}.resume()
}
AppState declaration:
class AppState: ObservableObject {
#Published var isGameActive = false
#Published var countryRegion = ""
#Published var adminDistrict = ""
}
post the struct that attaches the AppState environment object to the view hierarchy.
I have attached AppState in SceneDelegate to ContentView as my root View (if I get it right)
window.rootViewController = UIHostingController(rootView:ContentView().environmentObject(appState))
Should I attach it to every View that modifies AppState?