EnvironmentObject in SwiftUI - dataflow

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.

Related

SwiftUI navigation link does not load

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()
}

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.

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.

Why does compiling SwiftUI modifier without leading point compile?

By accident i added a modifier to my swift UI view without a leading point. And it compiled. And i can not wrap my head around why i did that.
Here some example Code:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(
destination: DetailView(),
label: {
Text("Goto Detail")
})
navigationTitle("ContentView")
}
}
}
struct DetailView: View {
var body: some View {
Text("Detail View")
.padding()
navigationTitle("Detail")
}
}
Somehow even stranger is that the first "wrong" modifier navigationTitle("ContentView") does just nothing.
The second one navigationTitle("Detail") lets the App crash when navigating to the View during runtime.
Similar to this is
struct DetailView: View {
var body: some View {
padding()
}
}
This View does compile but just crashes, if tried to shown with Previews. And it just can't be navigated to.
I would really expect this code to not even compile.
Is somebody able to explain this?
If you refer to a method by its simple name (e.g. navigationTitle), it's kind of like saying self.navigationTitle:
struct DetailView: View {
var body: some View {
Text("Detail View")
.padding()
self.navigationTitle("Detail")
}
}
This is valid, because self is also a View, and that modifier is available on all Views. It gives you a new view that is self, but with a navigation title of Detail. And you are using both of them as the body. Normally you can't return multiple things from a property, but it works here because the body protocol requirement is marked #ViewBuilder.
Of course, you are using self as the self's body, and you can't have self referencing views, so it fails at runtime.
If you add self. to the other cases, it's pretty easy to understand why they compile:
struct DetailView: View {
var body: some View {
self.padding() // self conforms to View, so you can apply padding()
// padding() returns another View, so it's valid to return here
}
}
"The body of DetailView is itself, but with some padding."
NavigationView {
NavigationLink(
destination: DetailView(),
label: {
Text("Goto Detail")
})
self.navigationTitle("ContentView")
}
"The navigation view consists of a navigation link, and ContentView itself (what self means here) with a navigation title of ContentView"
This doesn't crash immediately either because NavigationView's initialiser is smart enough to ignore the junk ContentView that you have given it, or because there is an extra level of indirect-ness to the self-referencing. I don't think we can know exactly why it crashes/doesn't crash immediately, until SwiftUI becomes open source.

Dismiss navigation view when Core Data object is deleted

I'm attempting to use SwiftUI and CoreData to build a macOS application. This application's main window has a NavigationView, with list items bound to a fetch request, and selecting any of these items populates the detail view. The navigation view goes kind of like this:
NavigationView {
VStack(spacing: 0) {
List(fetchRequest) { DetailRow(model: $0) }
.listStyle(SidebarListStyle())
HStack {
Button(action: add) { Text("+") }
Button(action: remove) { Text("-") }
}
}
Text("Select a model object")
}.navigationViewStyle(DoubleColumnNavigationViewStyle())
DetailRow is a NavigationLink that also defines the detail view:
NavigationLink(destination: ModelDetail(model: model)) {
Text(model.name)
}
I believe that the contents of ModelDetail isn't very important; either way, I'm fairly flexible with it.
In the navigation view, the "-" button, which calls the remove method, should delete the currently-selected model object and return to the default, empty detail view. Unfortunately, I'm struggling to come up with the right way to do this. I believe that I need the following interactions to happen:
subview communicates to navigation view which model object is currently selected
user clicks "-" button, navigation view's remove method deletes currently selected object
subview notices that its model object is being deleted
→ subview calls PresentationMode.dismiss()
Step 3 is the one I'm struggling with. Everything is working out alright so far without using view-model classes on top of the Core Data classes, but I feel stuck trying to figure out how to get the subview to call dismiss(). This needs to happen from the detail view, because it gets the PresentationMode from the environment, and the NavigationView changes it.
While I can get a Binding to the model's isDeleted property through #ObservedObject, I don't know how I can actually react to that change; Binding appears to use publishers under the hood, but they don't expose a publisher that I could hook up to with onPublish, for instance.
KVO over isDeleted might be possible, but listening from a value type isn't great; there's no good place to remove the observer, which could become problematic were the app to run for too long.
What's the guidance for this type of problem?
Heres my solution.
This is my NoteDetailView. It allows deletion from this view, or the "master" view in the Navigation hierarchy. This solution works on Mac, iPad, and iPhone.
I added an optional dateDeleted to my Entity. When a record is deleted, I simply add a value of Date() to this attribute and save the context. In my FetchRequests, I simply predicate for dateDeleted = nil. I'm going to add a trash can and stuff to my app later so people can view or permanently empty their trash.
Then I use a state variable and a notification to clear my View. You can change the code up for the functionality you want:
struct NoteDetailView: View {
var note: Note
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#Environment(\.managedObjectContext) var managedObjectContext
#State var noteBody: String = ""
#State var showEditNoteView: Bool = false
#State var showEmptyView: Bool = false
init(note: Note) {
self.note = note
self._noteBody = State(initialValue: note.body)
}
var body: some View {
VStack {
if (!showEmptyView) {
Text("NOT DELETED")
}
else {
EmptyView()
}
}
.navigationBarTitle(!showEmptyView ? note.title : "")
.navigationBarItems(trailing:
HStack {
if (!showEmptyView) {
Button(action: {
self.showEditNoteView.toggle()
}, label: {
NavBarImage(image: "pencil")
})
.sheet(isPresented: $showEditNoteView, content: {
EditNoteView(note: self.note).environment(\.managedObjectContext, self.managedObjectContext)
})
}
}
)
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave)) { _ in
if (self.note.dateDeleted != nil) {
self.showEmptyView = true
self.presentationMode.wrappedValue.dismiss()
}
}
}
}