I have a simple Loader that performs a loading animation when its #Publisher is set to true. This publisher is set to true on a ViewModel when making a network request. I would like this value to change my view, which it doesn't. The ContentView shown is more complex with many views inside each other, but it is the root view of them all.
And the loader is a Lottie view that works perfectly when everything is set to true manually. The Loader is a stand-alone #ObservableObject as I want to use it in many others ViewModels.
Why is my #ObservedObject var loader not performing any change in my view state when set from a ViewModel?
My loader:
final class Loader: ObservableObject {
#Published var isLoading = false
}
My view model which trigger the loader:
(In the console, the values of the loader are changing accordingly)
final class OnboardingViewModel: ObservableObject {
#ObservedObject var loader = Loader()
func demoLogin() {
loaderIsLoading(true) // loader set to true
AuthRequest.shared
.demoLogin()
.sink(
receiveCompletion: {
self.loaderIsLoading(false) }, // loader set to false
receiveValue: {
self.enterDemoAction()
print("Login: \($0.login)\nToken: \($0.token)") })
.store(in: &subscriptions)
}
func loaderIsLoading(_ action: Bool) {
DispatchQueue.main.async {
self.loader.isLoading = action
}
}
}
This is the SwiftUI view where I want the magic happens:
(It is much more complicated than this with subviews and components inside each other)
struct ContentView: View {
#EnvironmentObject var onboardingViewModel: OnboardingViewModel
#ObservedObject var loader = Loader()
var body: some View {
ZStack {
if loader.isLoading { // this is where it is not updated
LottieView(named: .loading,
loop: loader.isLoading,
width: 220, height: 220)
}
}
}
}
The #ObservedObject is a pattern of View, it has no sense outside, ie. in this case in model.
Here is a possible solution for provided code.
1) in model
final class OnboardingViewModel: ObservableObject {
var loader = Loader() // << just property
// ... other code
2) in view
struct ContentView: View {
#EnvironmentObject var onboardingViewModel: OnboardingViewModel
var body: some View {
InnerView(loader: onboardingViewModel.loader) // << pass from Env
}
struct InnerView: View {
#ObservedObject var loader: Loader
var body: some View {
ZStack {
if loader.isLoading { // this is where it is not updated
LottieView(named: .loading,
loop: loader.isLoading,
width: 220, height: 220)
}
}
}
}
}
Note: I don't see where you call onboardingViewModel.demoLogin(), but I expect it is activate somewhere, because as I see it is the only function that activates Loader.
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.
Let's say we have a parent view like:
struct ParentView: View {
#State var text: String = ""
var body: some View {
ChildView(text: $text)
}
}
Child view like:
struct ChildView: View {
#ObservedObject var childViewModel: ChildViewModel
init(text: Binding<String>) {
self.childViewModel = ChildViewModel(text: text)
}
var body: some View {
...
}
}
And a view model for the child view:
class ChildViewModel: ObservableObject {
#Published var value = false
#Binding var text: String
init(text: Binding<String>) {
self._text = text
}
...
}
Making changes on the String binding inside the child's view model makes the ChildView re-draw causing the viewModel to recreate itself and hence reset the #Published parameter to its default value. What is the best way to handle this in your opinion?
Cheers!
The best way is to use a custom struct as a single source of truth, and pass a binding into child views, e.g.
struct ChildViewConfig {
var value = false
var text: String = ""
// mutating funcs for logic
mutating func reset() {
text = ""
}
}
struct ParentView: View {
#State var config = ChildViewConfig()
var body: some View {
ChildView(config: $config)
}
}
struct ChildView: View {
#Binding var config: ChildViewConfig
var body: some View {
TextField("Text", text: $config.text)
...
Button("Reset") {
config.reset()
}
}
}
"ViewConfig can maintain invariants on its properties and be tested independently. And because ViewConfig is a value type, any change to a property of ViewConfig, like its text, is visible as a change to ViewConfig itself." [Data Essentials in SwiftUI WWDC 2020].
When I have a nested ObservedObject, changes in a published property of a nested object do not updated the UI until something happens to the parent object. Is this a feature, a bug (in SwiftUI) or a bug in my code?
Here is a simplified example. Clicking the On/Off button for the parent immediately updates the UI, but clicking the On/Off button for the child does not update until the parent is updated.
I am running Xcode 12.5.1.
import SwiftUI
class NestedObject: NSObject, ObservableObject {
#Published var flag = false
}
class StateObject: NSObject, ObservableObject {
#Published var flag = false
#Published var nestedState = NestedObject()
}
struct ContentView: View {
#ObservedObject var state = StateObject()
var body: some View {
VStack {
HStack {
Text("Parent:")
Button(action: {
state.flag.toggle()
}, label: {
Text(state.flag ? "On" : "Off")
})
}
HStack {
Text("Child:")
Button(action: {
state.nestedState.flag.toggle()
}, label: {
Text(state.nestedState.flag ? "On" : "Off")
})
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#ObservedObject or #StateObject updates the view when the ObservableObject updates. This happens when a #Published property is changed or when you directly call objectWillChange.send().
So, the "normal" (and the simplest) approach is to use a value type, e.g. a struct, for a #Published property.
struct NestedObject {
var flag = false
}
The reason this works, is that the entire NestedObject changes when its properties are modified, because struct is a value-type. In contrast, a reference-type class doesn't change (i.e. reference remains the same) when its property is modified.
But, sometimes you might need it to be a reference-type object, because it might have its own life cycle, etc...
In that case, you could definitely just call state.objectWillChange.send(), but that would only work if the view initiates the change, not when the nested object initiates the change. The best general approach here, in my opinion, is to use a nested inner view that has its own #ObservedObject to observe changes of the inner object:
struct ContentView: View {
private struct InnerView: View {
#ObservedObject var model: NestedObject
var body: some View {
Text("Child:")
Button(action: {
model.flag.toggle()
}, label: {
Text(model.flag ? "On" : "Off")
})
}
}
#StateObject var state = OuterObject() // see comments 1, 2 below
var body: some View {
VStack {
HStack {
Text("Parent:")
Button(action: {
state.flag.toggle()
}, label: {
Text(state.flag ? "On" : "Off")
})
}
HStack {
InnerView(model: state.nestedObject)
}
}
}
}
1. You shouldn't call your class StateObject, since it clashes with the StateObject property wrapper of SwiftUI. I renamed it to OuterObject.
2. Also, you should use #StateObject instead of #ObservedObject if you instantiate the object inside the view.
It works as supposed: ObservableObject only sends a notification about the changes of #Published properties but doesn't propagate change notification of nested ObservableObjects.
I'd follow the advice from New Dev: use a struct when you can or use a separate View to subscribe to the nested object.
If you truly need nested ObservableObjects, you can propagate the objectWillChange event from the nested object to the outer object using Combine:
import Combine
import SwiftUI
class InnerObject: ObservableObject {
#Published var flag = false
}
class OuterObject: ObservableObject {
#Published var flag = false
var innerObject = InnerObject() {
didSet {
subscribeToInnerObject()
}
}
init() {
subscribeToInnerObject()
}
private var innerObjectSubscription: AnyCancellable?
private func subscribeToInnerObject() {
// subscribe to the inner object and propagate the objectWillChange notification if it changes
innerObjectSubscription = innerObject.objectWillChange.sink(receiveValue: objectWillChange.send)
}
}
struct ContentView: View {
#ObservedObject var state = OuterObject()
var body: some View {
VStack {
Toggle("Parent \(state.flag ? "On" : "Off")", isOn: $state.flag)
Toggle("Child \(state.innerObject.flag ? "On" : "Off")", isOn: $state.innerObject.flag)
}
.padding()
}
}
Thank you for clarifications. The most valuable take-away for me is the fact that this behavior is not a bug (of SwiftUI), but is a by-design behavior.
SwiftUI (more precisely, Combine) see changes only in values, therefore, it can see changes in the property value changes of #Published struct instances, but not #Published class instances.
Therefore, the answer is "use struct instances for the nested objects if you want to update the UI based on the changes in the property values of those nested objects. If you have to use class instances, use another mechanism to explicitly notify changes".
Here is the modified code using struct for NestedObject instead of class.
import SwiftUI
struct NestedObject {
var flag = false
}
class OuterObject: NSObject, ObservableObject {
#Published var flag = false
#Published var nestedState = NestedObject()
}
struct ContentView: View {
#ObservedObject var state = OuterObject()
var body: some View {
VStack {
HStack {
Text("Parent:")
Button(action: {
state.flag.toggle()
}, label: {
Text(state.flag ? "On" : "Off")
})
}
HStack {
Text("Child:")
Button(action: {
state.nestedState.flag.toggle()
}, label: {
Text(state.nestedState.flag ? "On" : "Off")
})
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
try this, works well for me on ios-15, catalyst 15, macos 12, using xcode 13:
HStack {
Text("Child:")
Button(action: {
state.objectWillChange.send() // <--- here
state.nestedState.flag.toggle()
}, label: {
Text(state.nestedState.flag ? "On" : "Off")
})
}
Having a SwiftUI project generated by Xcode, and adding a custom MyView with a MyViewModel.
The ContentView just renders MyView.
The problem:
When the ContentView gets reloaded (the reload button changes its state), MyViewModel gets somehow disconnected from MyView (the MyView counter stops incrementing in the UI when the button is clicked), but the console logs show the incrementation works.
If the model subscribes to a publisher, it does not get unsubscribed because the instance is not released. Therefore, the instances still process incoming messages and alter the app's data and state.
Looking at the instance counters and memory addresses in the console:
Every time the ContentView gets refreshed, a new MyView and MyViewModel instances get created. However, the counter incrementation uses the original first-created model instance.
Some model instances did not get released.
EDIT: The model needs to be recreated every time MyView is recreated.
import SwiftUI
struct ContentView: View {
#State
private var reloadCounter = 0
var body: some View {
VStack {
Button(action: { self.reloadCounter += 1 },
label: { Text("Reload view") })
Text("Reload counter: \(reloadCounter)")
MyView().environmentObject(MyViewModel())
}
}
}
import SwiftUI
struct MyView: View {
#EnvironmentObject
private var model: MyViewModel
var body: some View {
VStack {
Button(action: { self.model.counter += 1 },
label: { Text("Increment counter") })
Text("Counter value: \(model.counter)")
}
.frame(width: 480, height: 300)
}
init() { withUnsafePointer(to: self) { print("Initialising MyView struct instance \(String(format: "%p", $0))") }}
}
import Combine
class MyViewModel: ObservableObject {
private static var instanceCount: Int = 0 { didSet {
print("SettingsViewModel: \(instanceCount) instances")
}}
#Published
var counter: Int = 0 { didSet {
print("Model counter: \(counter), self: \(Unmanaged.passUnretained(self).toOpaque())")
}}
init() { print("Initialising MyViewModel class instance \(Unmanaged.passUnretained(self).toOpaque())"); Self.instanceCount += 1 }
deinit { print("Deinitialising MyViewModel class instance \(Unmanaged.passUnretained(self).toOpaque())"); Self.instanceCount -= 1 }
}
Any clue what did I do wrong?
The image below depicts the app's UI after all the actions in the logs were performed.
You create new view model on every refresh, so just move it outside body, like
struct ContentView: View {
#State
private var reloadCounter = 0
private let vm = MyViewModel() // << here !!
var body: some View {
VStack {
Button(action: { self.reloadCounter += 1 },
label: { Text("Reload view") })
Text("Reload counter: \(reloadCounter)")
MyView().environmentObject(vm) // << reference only !!
}
}
}
Note: if you don't need it deeply in sub-view hierarchy then consider to use #ObservedObject instead of #EnvironmentObject and pass reference via constructor (because environment object is stored somewhere outside and you have less control over its life-cycle)
In my app, there is a singleton instance, AppSetting, which is used in the entire views and models. AppSetting has a variable, userName.
class AppSetting: ObservableObject {
static let shared = AppSetting()
private init() { }
#Published var userName: String = ""
}
ParentView prints userName when it is not empty. At first, it is empty.
struct ParentView: View {
#State var isChildViewPresented = false
#ObservedObject var appSetting = AppSetting.shared
var body: some View {
ZStack {
Color.white.edgesIgnoringSafeArea(.all)
VStack {
Button(action: { self.isChildViewPresented = true }) {
Text("Show ChildView")
}
if !appSetting.userName.isEmpty { // <--- HERE!!!
Text("\(appSetting.userName)")
}
}
if isChildViewPresented {
ChildView(isPresented: $isChildViewPresented)
}
}
}
}
When a user taps the button, userName will be set.
struct ChildView: View {
#Binding var isPresented: Bool
#ObservedObject var childModel = ChildModel()
var body: some View {
ZStack {
Color.white.edgesIgnoringSafeArea(.all)
VStack {
Button(action: { self.childModel.setUserName() }) { // <--- TAP BUTTON HERE!!!
Text("setUserName")
}
Button(action: { self.isPresented = false }) {
Text("Close")
}
}
}
}
}
class ChildModel: ObservableObject {
init() { print("init") }
deinit { print("deinit") }
func setUserName() {
AppSetting.shared.userName = "StackOverflow" // <--- SET userName HERE!!!
}
}
The problem is when userName is set, the instance of ChildModel is invalidated. I think when ParentView adds Text("\(appSetting.userName)"), it changes its view hierarchy and then it makes SwiftUI delete the old instance of ChildModel and create a new one. Sadly, it gives me tons of bug. In my app, the ChildModel instance must be alive until a user explicitly closes ChildView.
How can I make the ChildModel instance alive?
Thanks in advance.
It is possible when to de-couple view & view model and inject dependency via constructor
struct ChildView: View {
#Binding var isPresented: Bool
#ObservedObject var childModel: ChildModel // don't initialize
// ... other your code here
store model somewhere externally and inject when show child view
if isChildViewPresented {
// inject ref to externally stored ChildModel()
ChildView(isPresented: $isChildViewPresented, viewModel: childModel)
}