Why is viewModel not deiniting with a NavigationView? - swift

I've got a problem with an object not being destroyed when put into the environment and used with a NavigationView. If a view creates an object, creates a NavigationView and inserts the object into the environment, then when that view disappears the object should be deallocated, but it's not.
The example app below should demonstrate this issue!
Look at the LoggedInView where the ViewModel is created: When the user logs out, the LoggedInView disappears, but the deinit of ViewModel is not called.
What needs to change so that the ViewModel is deinitialized when the LoggedInView disappears?
Here is an example I've created to demo the issue:
struct ContentView: View {
#State private var loggedIn = false
var body: some View {
if loggedIn {
LoggedInView(loggedIn: $loggedIn)
} else {
Button("Log In") {
loggedIn = true
}
}
}
}
struct LoggedInView: View {
#Binding var loggedIn: Bool
#StateObject private var viewModel = ViewModel()
var body: some View {
NavigationView {
Button("Log Off") {
loggedIn = false
}
}
.environmentObject(viewModel)
}
}
class ViewModel: ObservableObject {
#Published var name = "Steve"
private let uuid = UUID().uuidString
init() {
print("initing " + uuid)
}
deinit {
print("deiniting " + uuid)
}
}
If I comment out either the NavigationView { or the .environmentObject(viewModel) then the ViewModel is deallocated as expected.
If I log in and out a few times the console looks like this:
initing 5DA78F23-6EC7-4F06-82F8-6066BB82272E
deiniting 5DA78F23-6EC7-4F06-82F8-6066BB82272E
initing 5BEABAF1-31D0-465E-A35E-94E5E141C453
deiniting 5BEABAF1-31D0-465E-A35E-94E5E141C453
initing 7A4F54FA-7C32-403B-9F47-1ED6289F68B5
deiniting 7A4F54FA-7C32-403B-9F47-1ED6289F68B5
However, if I run the code as above then the console looks like this:
initing D3C8388D-A0A0-42C9-B80A-21254A868060
initing 8DFFF7F7-00C4-4B9F-B592-85422B3A01C0
initing F56D4869-A724-4E10-8C1A-CCA9D99EE1D3
initing ED985C33-C107-4D8C-B51D-4A9D1320F784
deiniting 8DFFF7F7-00C4-4B9F-B592-85422B3A01C0
initing CCD18C41-0196-44B0-B414-697B06E0DE2F
deiniting ED985C33-C107-4D8C-B51D-4A9D1320F784
initing EAA0255A-5F2E-406D-805A-DE2D675F7964
initing 49908FAA-8573-4EBE-8E8B-B0120A504DDA
What's interesting here is that the User object does sometimes get deallocated but it seems only after it's created multiple instances and it never seems to deallocated the first few that were created.

This is happening, IMO, because you are creating two strong pointers to the class User, one by making it an #StateObject and other by putting it on "environmentObject".
Try to decouple your User model as a struct and store the UUID there
Create a class to serve as a Storage Only View Model it will only Publish the User struct
Instantiate this View Model as #StateObject into your view and use your model as you wish.
Get rid of your environmentObject, pass along your view model as needed.
PS: Only use environmentObject after a deep understanding about what they are. For now I'll not lecture on environmentObjects

The most straight-forward solution for your simplified example would be to move the .environmentObject(viewModel) modifier: instead of applying it to the NavigationView, apply it to the Button.
This works if the ViewModel is only needed in children of LoggedInView which aren't wrapped in a NavigationLink.
However, when you apply this approach to a scenario that actually expects the ViewModel to be accessible in any destination views wrapped in NavigationLinks, you would run into the problem of NavigationLink shielding its destination from any .environmentObjects that aren't either a) applied to the enclosing NavigationView or b) applied to NavigationLink's destinations directly.
I've tried a couple of approaches to work around this in your specific setup, but SwiftUI's "optimizations" around if/else statements, as well as #StateObject initialization, cause even solutions using the .id() modifier, AnyView(), and EquatableView() to fail.
All of these should provide custom control over a View's identity, as described in this WWDC talk: Demystify SwiftUI but I believe the specific structure you're looking for touches on too many idiosyncratic components to provide a clean outcome, leaving me to recommend the following as the only solution that maintains your described hierarchy but also deinits the ViewModel as desired:
Remove .environmentObject() from the NavigationView
Apply .environmentObject(viewModel) to all immediate child views (or a Group/Container) inside LoggedInView that need access to the ViewModel
Do the same for any destination views inside of NavigationLinks, but apply .environmentObject(viewModel) directly to the destination view, not to the enclosing NavigationLink.
So a sample implementation looks like this:
struct LoggedInView: View {
#Binding var loggedIn: Bool
#StateObject private var viewModel = ViewModel()
var body: some View {
NavigationView {
List {
Button("Log Off") {
loggedIn = false
}
DeeperViewDependingOnViewModel()
.environmentObject(viewModel)
NavigationLink {
DeeperViewDependingOnViewModel()
.environmentObject(viewModel)
} label: {
Text("Go Deeper")
}
}
// This alone would suffice if no NavigationLink destinations need to access ViewModel:
//.environmentObject(viewModel)
}
}
}

This is an insane Apple issue which I'm dealing last several days. As I correctly understood such behaviour is a kinda of specific Apple logic for NavigationView/NavigationStackView added to the root of an app hierarchy.
Here is my investigations on it, maybe you can find a workaround which works for you:
NavigationView retains in memory all ObservableObjects shared via .environmentObject() method applied on it if and only if NavigationView is placed as a root view of an app.
So if you try to replace some view with NavigationView in a root of an hierarchy (login/logout like in your example), then all shared environment objects of navigation would retain in memory.
To prove this statement I tried to present NavigationView modally (using .sheet() or .fullScreenCover()) and share ObservableObject via .environmentObject() on it. When presented NavigationView dismisses, SwiftUI successfully deallocates all shared environments objects from memory.
Conclusion:
I assume that this is an Apple "feature" we didn't ask for...
The thing is that there are a lot of optimisations in SwiftUI and many of them are related to the memory management - inits/deinits of reference type state objectes stored in SwiftUI Views are optimised as well.
So Apple may assume that if NavigationView is a root view, then it always should be there (kinda logical... no? :D) and if you even replace it with other view, you always would return to NavigationView...
So for the sake of optimizations Apple doesn't remove state objects attached to it 🤔
Of course this is just an assumption. But you may try to present authorized flow view with navigation modally from your authorization view... this is the only solution I've found so far. If you find something better, ping me pleas :)
Also, you may change your architecture and make your ViewModel a shared one for the whole app and clean its state on logout.
But the most correct way, I suppose, is that we have to file a bug for Apple. Cause it's definitely a bug, not a feature :)

Related

SwiftUI: How do I share an ObservedObject between child views without affecting the parent view

TL;DR: If I have a view containing a NavigationSplitView(sidebar:detail:), with a property (such as a State or StateObject) tracking user selection, how should I make it so that the sidebar and detail views observe the user selection, but the parent view does not?
Using SwiftUI's new NavigationSplitView (or the deprecated NavigationView), a common paradigm is to have a list of selectable items in the sidebar view, with details of the selected item in the detail view. The selection, of course, needs to be observed, usually from within an ObservedObject.
struct ExampleView: View {
#StateObject private var viewModel = ExampleViewModel()
var body: some View {
NavigationSplitView {
SidebarView(selection: $viewModel.selection)
} detail: {
DetailView(item: viewModel.selection)
}
}
}
struct SidebarView: View {
let selectableItems: [Item] = []
#Binding var selection: Item?
var body: some View {
List(selectableItems, selection: $viewModel.selected) { item in
NavigationLink(value: item) { Text(item.name) }
}
}
}
struct DetailView: View {
let item: Item?
var body: some View {
// Details of the selected item
}
}
#MainActor
final class ExampleViewModel: ObservableObject {
#Published var selection: Item? = nil
}
This, however, poses a problem: the ExampleView owns the ExampleViewModel used for tracking the selection, which means that it gets recalculated whenever the selection changes. This, in turn, causes its children SidebarView and DetailView to be redrawn.
Since we want those children to be recalculated, one might be forgiven for thinking that everything is as intended. However, the ExampleView itself should not be recalculated in my opinion, because doing so will not only update the child views (intended), but also everything in the parent view (not intended). This is especially true if its body is composed of other views, modifiers, or setup work. Case in point: in this example, the NavigationSplitView itself will also be recalculated, which I don't think is what we want.
Almost all tutorials, guides and examples I see online use a version of the above example - sometimes the viewModel is passed as an ObservedObject, or as an EnvironmentObject, but they all share the same trait in that the parent view containing the NavigationSplitView is observing the property that should only be observed by the children of NavigationSplitView.
My current solution is to initiate the viewmodel in the parent view, but not observe it:
struct ExampleView: View {
let viewModel = ExampleViewModel()
...
}
#MainActor
final class ExampleViewModel: ObservableObject {
#Published var selection: Item? = nil
nonisolated init() { }
}
This way, the parent view will remain intact (at least in regards to user selection); however, this will cause the ExampleViewModel to be recreated if anything else would cause the ExampleView to be redrawn - effectively resetting our user selection. Additionally, we are unable to pass any of the viewModel's properties as bindings. So while it works for my current use-case, I don't consider this an effective solution.

How can I replace the root view in SwiftUI using the NavigationStack?

I am currently playing with the new NavigationStack component in SwiftUI and I would like to recreate a scenario where the app remembers a user and when the user logs out of the app it goes back to a view that is not pushed into the stack.
I notice that the root view in the NavigationStack can not be programatically changed because when the array that holds the stack always starts empty when you add the NavigationStack in a view (the view that is currently visible). Is there a way to swap the root view?
I had a similar scenario and I solved it by using this package. You can forward users to a specific screen by using a screen id.
After doing some research I realized that you can't replace the root view. According to Apple's documentation:
So if you want to change the root you need to do something like this:
struct ContentView: View {
#State var isLogged = false
// or you could use an observableObject to handle this state
var body: some View {
NavigationStack {
if isLogged {
LoggedView()
} else {
LoginView()
}
}
}
}

SwiftUI and MVVM: Is using multiple viewModels in one view 'valid'?

This is more a general question: I'm working on my first SwiftUI project and using MVVM for the first time as well. After programming some views I realized that I need for almost each view two different view models. Often it's the view model for the current view and the view model of the previous/mother view. Is this "normal" or is this a hint that I've designed my project wrong and abusing MVVM?
For example:
I have a view where I list all flashcard decks. For this view I have a decksViewModelthat looks like this:
class DeckListViewModel: ObservableObject{
#Published var decks = [Deck]
#Published var showDeck = false // this value will be true if i tab on a deck and the deck will shown in a detailed view. This value is checked in the list
#Published var expandButton = false
#Published var showDownloadCenter = false
#Published var showCreateDeck = false
}
Now I have a deckDetailView for the detailed view of my deck. The deckDetailViewModel stores the selected item. But to remove this view I need to change the value of the showDeck? in decksViewModel`. So I need to pass this view model as well.
I wouldn't say that's a good way to use MVVM.
Instead of storing showDeck in DeckListViewModel, you could just have it as a local #State variable in whatever view you're using it in. Or if you're using a NavigationView, just use a NavigationLink, and there'll be no need for any state variable.
struct DecksView: View {
#ObservedObject var deckListVM = DeckListViewModel()
var body: some View {
NavigationView {
VStack {
ForEach(deckListVM.decks) { deck in
NavigationLink(destination: DeckDetailView(deckDetailVM: DeckDetailViewModel(deck: deck))) {
// some view
}
}
}
}
}
}
I'm not sure how you implemented your DeckDetailViewModel, just guessing there.

SwiftUI - How to deinit a StateObject when navigating back?

I want my #StateObject to be deinitialized as soon as possible after I navigate back, but it seems that the object is held in memory. "Deint ViewModel" is not being printed on back navigation, its first printed after I navigate again to the View I was coming from. Is there a way to release the #StateObject from memory on back navigation?
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: TestView(), label: { Text("Show Test View") })
}
}
}
struct TestView: View {
#StateObject private var viewModel = ViewModel()
var body: some View {
Text("Test View")
}
}
final class ViewModel: ObservableObject {
deinit {
print("Deint ViewModel")
}
}
I don't have a brilliant answer on all situations that prevent the deinitialization the #StateObject, but I found that leaving background async tasks running prevents the deinitialization.
In my case, I had several cancellables registered to listen to PassthroughSubject and/or CurrentValueSubject (that I used to handle external changes on my model and exposing the result to the view), but I never cancelled them. As soon as I did it in the view using .onDisappear, it worked.
So my views all "subscribe" to the view model (I have a viewModel.subscribe() method) using .onAppear and then "unsubscribe" to the view model (I have a viewModel.subscribe() method) using .onDisappear. Doing so, the #StateObject is deinitialized when the view is dismissed.
Adding to GregP's answer:
If you have removed all of your cancellables on onDisappear and deinit is still not being called, you may use the Debug Memory Graph
Navigate to the object, see its tree and see what else is referencing it.
For example I had it looking like this:
Because there was another object referencing this object it didn't get removed from the memory (ARC). So all I had to do was remove it from being the delegate along with cancelling the cancellables and deinit got called
I think you should use #ObservedObject private var viewModel: ViewModel instead, then inject new ViewModel instance from outside of TestView

ObervableObject being init multiple time, and not refreshing my view

I have a structure like this:
contentView {
navigationView {
foreach {
NavigationLink(ViewA(id: id))
}
}
}
/// where ViewA contains an request trigger when it appears
struct ViewA: View {
#State var filterString: String = ""
var id: String!
#ObservedObject var model: ListObj = ListObj()
init(id: String) {
self.id = id
}
var body: some View {
VStack {
SearchBarView(searchText: $filterString)
List {
ForEach(model.items.filter({ filterString.isEmpty || $0.id.contains(filterString) || $0.name.contains(filterString) }), id: \.id) { item in
NavigationLink(destination: ViewB(id: item.id)) {
VStack {
Text("\(item.name) ")
}
}
}
}
}
.onAppear {
self.model.getListObj(id: self.id) //api request, fill data and call objectWillChange.send()
}
}
}
ViewB has the same code as ViewA: It receives an id, stores and requests an API to collect data.
But the viewB list is not being refreshed.
I also noticed that viewB's model property
#ObservedObject var model: model = model()
was instantiated multiple times.
Debugging, I found that every navigationLink instantiates its destination even before it is triggered. That's not a problem usually, but in my case i feel like the ViewB model is being instantiated 2 times, and my onAppear call the wrong one, reason why self.objectWillChange.send() not refreshing my view.
There are two issues here:
SwiftUI uses value types that that get initialized over and over again each pass through body.
Related to #1, NavigationLink is not lazy.
#1
A new ListObj gets instantiated every time you call ViewA.init(...). ObservedObject does not work the same as #State where SwiftUI keeps careful track of it for you throughout the onscreen lifecycle. SwiftUI assumes that ultimate ownership of an #ObservedObject exists at some level above the View it's used in.
In other words, you should almost always avoid things like #ObservedObject var myObject = MyObservableObject().
(Note, even if you did #State var model = ListObj() it would be instantiated every time. But because it's #State SwiftUI will replace the new instance with the original before body gets called.)
#2
In addition to this, NavigationLink is not lazy. Each time you instantiate that NavigationLink you pass a newly instantiated ViewA, which instantiates your ListObj.
So for starters, one thing you can do is make a LazyView to delay instantiation until NavigationLink.destination.body actually gets called:
// Use this to delay instantiation when using `NavigationLink`, etc...
struct LazyView<Content: View>: View {
var content: () -> Content
var body: some View {
self.content()
}
}
Now you can do NavigationLink(destination: LazyView { ViewA() }) and instantiation of ViewA will be deferred until the destination is actually shown.
Simply using LazyView will fix your current problem as long as it's the top view in the hierarchy, like it is when you push it in a NavigationView or if you present it.
However, this is where #user3441734's comment comes in. What you really need to do is keep ownership of model somewhere outside of your View because of what was explained in #1.
If your #ObservedObject is being initialized multiple times, it is because the owner of the object is refreshed and recreated every time it has state changes. Try to use #StateObject if your app is iOS 14 and above. It prevents the object from being recreated when the view refreshes.
https://developer.apple.com/documentation/swiftui/stateobject
When a view creates its own #ObservedObject instance it is recreated
every time a view is discarded and redrawn. On the contrary a #State
variable will keep its value when a view is redrawn. A #StateObject is
a combination of #ObservedObject and #State - the instance of the
ViewModel will be kept and reused even after a view is discarded and
redrawn
What is the difference between ObservedObject and StateObject in SwiftUI