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.
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 not quite sure I understand what is going on here as I am experimenting with an EnvironmentObject in SwiftUI.
I recreated my problem with a small example below, but to summarize: I have a ContentView, ContentViewModel, and a StateController. The ContentView holds a TextField that binds with the ContentViewModel. This works as expected. However, if I update a value in the StateController (which to me should be completely unrelated to the ContentViewModel) the text in the TextField is rest.
Can someone explain to me why this is happening, and how you could update a state on an EnvironmentObject without having SwiftUI redraw unrelated parts?
App.swift
#main
struct EnvironmentTestApp: App {
#ObservedObject var stateController = StateController()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(stateController)
}
}
}
ContentView.swift
struct ContentView: View {
#ObservedObject private var viewModel = ContentViewModel()
#EnvironmentObject private var stateController: StateController
var body: some View {
HStack {
TextField("Username", text: $viewModel.username)
Button("Update state") {
stateController.validated = true
}
}
}
}
ContentViewModel.swift
class ContentViewModel: ObservableObject {
#Published var username = ""
}
StateController.swift
class StateController: ObservableObject {
#Published var validated = false
}
Like lorem-ipsum pointed out, you should use #StateObject.
A good rule of thumb is to use #StateObject every time you init a viewModel, but use #ObservedObject when you are passing in a viewModel that has already been init.
When using the SceneDelegate in SwiftUI, it was possible to create a function like the one below that could be used to set the view as shown here. However, in the latest version we now use a WindowsGroup. Is it possible to write a function that changes the view in the WindowsGroup?
func toContentView() {
let contentView = ContentView()
window?.rootViewController = UIHostingController(rootView: contentView)
}
Here is possible alternate approach that do actually the same as your old toContentView
helper class
class Resetter: ObservableObject {
static let shared = Resetter()
#Published private(set) var contentID = UUID()
func toContentView() {
contentID = UUID()
}
}
content of #main
#StateObject var resetter = Resetter.shared
var body: some Scene {
WindowGroup {
ContentView()
.id(resetter.contentID)
}
}
now from anywhere in code to reset to ContentView you can just call
Resetter.shared.toContentView()
If you want to pass an #EnvironmentObject to a View presented as a sheet, you'll notice that this sheet gets recreated every single time any #Published property in the #EnvironmentObject is updated.
Minimum example that demonstrates the problem:
import SwiftUI
class Store: ObservableObject {
#Published var name = "Kevin"
#Published var age = 38
}
struct ContentView: View {
#EnvironmentObject private var store: Store
#State private var showProfile = false
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Edit profile") {
self.showProfile = true
}
}
.sheet(isPresented: $showProfile) {
ProfileView()
.environmentObject(self.store)
}
}
}
struct ProfileView: View {
#EnvironmentObject private var store: Store
#ObservedObject private var viewModel = ViewModel()
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Change age") {
self.store.age += 1
}
}
}
}
class ViewModel: ObservableObject {
init() {
print("HERE")
}
}
If you run this code, you'll notice that "HERE" gets logged every single time you press the button in the sheet, meaning that the ViewModel got recreated. This can be a huge problem as you might imagine, I expect the ViewModel to not get recreated but retain its state. It's causing huge problems in my app.
As far as I am aware, what I am doing in my code is the normal way to pass the #EnvironmentObject to a sheet. Is there a way to prevent the ProfileView from getting recreated any time something in the Store changes?
This is because the view gets recreated when a state variable changes. And in your view you instantiate the viewModel as ViewModel().
Try passing the observed object as a param and it won't hit "HERE" anymore:
struct ContentView: View {
#EnvironmentObject private var store: Store
#State private var showProfile = false
#ObservedObject private var viewModel = ViewModel()
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Edit profile") {
self.showProfile = true
}
}
.sheet(isPresented: $showProfile) {
ProfileView(viewModel: self.viewModel)
.environmentObject(self.store)
}
}
}
struct ProfileView: View {
#EnvironmentObject private var store: Store
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Text("Hello, \(store.name), you're \(store.age) years old")
Button("Change age") {
self.store.age += 1
}
}
}
}
If your Deployment Target is iOS14 and above, have you tried replacing #ObservedObject with #StateObject in ProfileView? This will help in keeping the state, it will only be created once, even if the Model View instantiaton happens inside the View's body.
A very nice article about this issue can be found her.
In this specific case, when I try to change an #EnvironmentObject's #Published var, I find that the view is not invalidated and updated immediately. Instead, the change to the variable is only reflected after navigating away from the modal and coming back.
import SwiftUI
final class UserData: NSObject, ObservableObject {
#Published var changeView: Bool = false
}
struct MasterView: View {
#EnvironmentObject var userData: UserData
#State var showModal: Bool = false
var body: some View {
Button(action: { self.showModal.toggle() }) {
Text("Open Modal")
}.sheet(isPresented: $showModal, content: {
Modal(showModal: self.$showModal)
.environmentObject(self.userData)
} )
}
}
struct Modal: View {
#EnvironmentObject var userData: UserData
#Binding var showModal: Bool
var body: some View {
VStack {
if userData.changeView {
Text("The view has changed")
} else {
Button(action: { self.userData.changeView.toggle() }) {
Text("Change View")
}
}
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
MasterView().environmentObject(UserData())
}
}
#endif
Is this a bug or am I doing something wrong?
This works if changeView is a #State var inside Modal. It also works if it's a #State var inside MasterView with a #Binding var inside Modal. It just doesn't work with this setup.
A couple of things.
Your setup doesn't work if you move the Button into MasterView either.
You don't have a import Combine in your code (don't worry, that alone doesn't help).
Here's the fix. I don't know if this is a bug, or just poor documentation - IIRC it states that objectWillChange is implicit.
Along with adding import Combine to your code, change your UserData to this:
final class UserData: NSObject, ObservableObject {
var objectWillChange = PassthroughSubject<Void, Never>()
#Published var changeView: Bool = false {
willSet {
objectWillChange.send()
}
}
}
I tested things and it works.
Changing
final class UserData: NSObject, ObservableObject {
to
final class UserData: ObservableObject {
does fix the issue in Xcode11 Beta6. SwiftUI does seem to not handle NSObject subclasses implementing ObservableObject correctly (at least it doesn't not call it's internal willSet blocks it seems).
In Xcode 11 GM2, If you have overridden objectWillChange, then it needs to call send() on setter of a published variable.
If you don't overridden objectWillChange, once the published variables in #EnvironmentObject or #ObservedObject change, the view should be refreshed. Since in Xcode 11 GM2 objectWillChange already has a default instance, it is no longer necessary to provide it in the ObservableObject.