SwiftUI navigation link does not load - swift

When clicking on the navigation link in my SwiftUI application, the screen freezes and I can see the memory doubing every second - almost getting to a 1GB of memory before I terminate the application.
I have a simple navigation link as follows:
NavigationLink {
FeedbackView(viewModel: .init())
} label: {
HStack {
Label("Feedback", systemImage: "bubble.left.circle")
.fontWeight(.semibold)
Spacer()
Image(systemName: "chevron.right")
}
}
Upon clicking on this navigtion link, the screen does not go to the next view and instead freezes. I am unable to tap anything else in the iOS simulator. The memory skyrockets and continues to do so until I stop the application.
The view model is it initializing in the FeedbackView call is as follows.
import Foundation
import Dependencies
class FeedbackViewModel: ObservableObject {
}
The view is below.
import SwiftUI
struct FeedbackView: View {
#ObservedObject var viewModel: FeedbackViewModel
var body: some View {
Text("loaded feedback")
}
}
If I remove .init() from the FeedbackView call within the NavigationLink, and instead initialize the FeedbackViewModel in the FeedbackView itself, I also get the same issue. I am rather new to iOS development and am not sure of which xCode tools to use that could help me diagnose this bug.

First: use #StateObject instead:
struct FeedbackView: View {
#StateObject var viewModel: FeedbackViewModel
var body: some View {
Text("loaded feedback")
}
}
Why: unlike #ObservedObject, #StateObject won't get destroyed and re-instantiated every time the view struct redraws. Never create #ObservedObject from within the view itself (Check this article for more details)
Second: how to initialize the #StateObject?
Really depends on your use case, but you could do this:
struct FeedbackView: View {
...
init(_ providedViewModel: ViewModel = ViewModel()) {
_viewModel = StateObject(wrappedValue: providedViewModel)
}
...
The "special notation" _viewModel refers to actual property wrapped inside StateObject property wrapper.
This way parent can either pass the model in (if there's some data it needs to initialize), or let the default model to be created:
NavigationLink {
FeedbackView()
}

Related

Run action when view is 'removed'

I am developing an app which uses UIKit. I have integrated a UIKit UIViewController inside SwiftUI and everything works as expected. I am still wondering if there is a way to 'know' when a SwiftUI View is completely gone.
My understanding is that a #StateObject knows this information. I now have some code in the deinit block of the corresponding class of the StateObject. There is some code running which unsubscribes the user of that screen.
The problem is that it is a fragile solution. In some scenario's the deinit block isn't called.
Is there any recommended way to know if the user pressed the back button in a SwiftUI View (or swiped the view away)? I don't want to get notified with the .onDisppear modifier because that is also called when the user taps somewhere on the screen which adds another view to the navigation stack. I want to run some code once when the screen is completely gone.
Is there any recommended way to know if the user pressed the back button in a SwiftUI View (or swiped the view away)?
This implies you're using a NavigationView and presenting your view with a NavigationLink.
You can be notified when the user goes “back” from your view by using one of the NavigationLink initializers that takes a Binding. Create a custom binding and in its set function, check whether the old value is true (meaning the child view was presented) and the new value is false (meaning the child view is now being popped from the stack). Example:
struct ContentView: View {
#State var childIsPresented = false
#State var childPopCount = 0
var body: some View {
NavigationView {
VStack {
Text("Child has been popped \(childPopCount) times")
NavigationLink(
"Push Child",
isActive: Binding(
get: { childIsPresented },
set: {
if childIsPresented && !$0 {
childPopCount += 1
}
childIsPresented = $0
}
)
) {
ChildView()
}
}
}
}
}
struct ChildView: View {
var body: some View {
VStack {
Text("Sweet child o' mine")
NavigationLink("Push Grandchild") {
GrandchildView()
}
}
}
}
struct GrandchildView: View {
var body: some View {
VStack {
Text("👶")
.font(.system(size: 100))
}
}
}
Note that these initializers, and NavigationView, are deprecated if your deployment target is iOS 16. In that case, you'll want to use a NavigationStack and give it a custom Binding that performs the pop-detection.

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

What is the lifecycle of #State variables in SwiftUI?

If I create a new #State variable, when does it get destroyed? Does it live for the lifetime of the parent UIHostingController?
As far as I can find, it is not documented. This is relevant because I don't understand how to clean up after myself if I create an ObservableObject as State somewhere in the view hierarchy.
import SwiftUI
struct Example: View {
#State private var foo = Foo()
var body: some View {
Text("My Great View")
}
}
class Foo: ObservableObject {
deinit {
// When will this happen?
print("Goodbye!")
}
}
Assuming:
struct Example: View {
#State private var foo = Foo()
var body: some View {
Text("My Great View")
}
}
class Foo: ObservableObject {
init() {
print(#function)
}
deinit {
print(#function)
}
}
The issue is that a View type is a struct, and it's body is not a collection of functions that are executed in real-time but actually initialized at the same time when View's body is rendered.
Problem Scenario:
struct ContentView: View {
#State var isPresented = false
var body: some View {
NavigationView {
NavigationLink(destination: Example()) {
Text("Test")
}
}
}
}
If you notice, Example.init is called before the navigation even occurs, and on pop Example.deinit isn't called at all. The reason for this is that when ContentView is initialized, it has to initialize everything in it's body as well. So Example.init will be called.
When we navigate to Example, it was already initialized so Example.init is not called again. When we pop out of Example, we just go back to ContentView but since Example might be needed again, and since it is not created in real-time, it is not destroyed.
Example.deinit will be called only when ContentView has to be removed entirely.
I wasn't sure on this but found another article talking about a similar issue here:
SwiftUI and How NOT to Initialize Bindable Objects
To prove this, lets ensure the ContentView is being completely removed.
The following example makes use of an action sheet to present and remove it from the view hierarchy.
Working Scenario:
struct ContentView: View {
#State var isPresented = false
var body: some View {
Button(action: { self.isPresented.toggle() }) {
Text("Test")
}
.sheet(isPresented: $isPresented) {
Example()
.onTapGesture {
self.isPresented.toggle()
}
}
}
}
PS: This applies to classes even if not declared as #State, and does not really have anything to do with ObservableObject.
In iOS 14, the proper way to do this is to use #StateObject. There is no safe way to store a reference type in #State.

SwiftUI - Presenting a View on top of the current View from AppDelegate

I am working on a project that at some point it receives a notification. When that happens, I need to show a View. I am not able to catch notification from any View so I am looking for a way to change to control it from outside of View structs. After the View's purpose is done, I need to dismiss it where the app left off. Think like the native behaviour when there is an active call.
I thought I could use sheet however I could not find any way to trigger it for every View that could be active when the notifications come. Or maybe trying to extend native View class would work but again, no luck finding a tutorial.
Any help will be appreciated.
Just update your model based on notification. There is not necessary to define .sheet (modal view) everywhere in your view hierarchy. Doing it in root view should be enough.
To demonstrate that (copy - paste - run) I create small project where I mimic notification with SwiftUI Toggle.
import SwiftUI
class Model: ObservableObject {
#Published var show = false
}
struct SubView: View {
#EnvironmentObject var model: Model
var tag: Int
var body: some View {
VStack {
NavigationLink(destination: SubView(tag: tag + 1).environmentObject(model)) {
Text("subview \(tag)")
}
if tag == 2 {
Toggle(isOn: $model.show) {
Text("toggle")
}.padding()
}
}.navigationBarTitle("subview \(tag)")
}
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
NavigationView {
SubView(tag: 0).environmentObject(model)
}.sheet(isPresented: $model.show) {
Text("sheet")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
with the result

EnvironmentObject in SwiftUI

To my knowledge, I should be able to use EnvironmentObject to observe & access model data from any view in the hierarchy. I have a view like this, where I display a list from an array that's in LinkListStore. When I open AddListView and add an item, it correctly refreshes the ListsView with the added item. However, if I use a PresentationButton to present, I have to do AddListView().environmentObject(listStore), otherwise there will be a crash when showing AddListView. Is my basic assumption correct (and this is behavior is most likely a bug) or am I misunderstanding the use of EnvironmentObject?
Basically: #State to bind a variable to a view in the same View (e.g. $text to TextField), #ObjectBinding/BindableObject to bind variables to other Views, and EnvironmentObject to do the same as #ObjectBinding but without passing the store object every time. With this I should be able to add new items to an array from multiple views and still refresh the Lists View correctly? Otherwise I don't get the difference between ObjectBinding and EnvironmentObject.
struct ListsView : View {
#EnvironmentObject var listStore: LinkListStore
var body: some View {
NavigationView {
List {
NavigationButton(destination: AddListView()) {
HStack {
Image(systemName: "plus.circle.fill")
.imageScale(.large)
Text("New list")
}
}
ForEach(listStore.lists) { list in
HStack {
Image(systemName: "heart.circle.fill")
.imageScale(.large)
.foregroundColor(.yellow)
Text(list.title)
Spacer()
Text("\(list.linkCount)")
}
}
}.listStyle(.grouped)
}
}
}
#if DEBUG
struct ListsView_Previews : PreviewProvider {
static var previews: some View {
ListsView()
.environmentObject(LinkListStore())
}
}
#endif
From Apple docs EnvironmentObject:
EnvironmentObject
A dynamic view property that uses a bindable object supplied by an ancestor view to invalidate the current view whenever the bindable object changes.
It translates as the binding affects the current view hierarchy. My guess is that when you are presenting a new view via PresentationButton, you are creating a new hierarchy, which is not rooted in your view -- the one you have supplied the object to. I'd guess the workaround here is to add the object to the "global" environment by implementing a struct that confirms to the EnvironmentKey protocol.