SwiftUI .onDisappear - swift

For the application I am designing, I have a prominent header that we want to appear on our Homepage. I want it to disappear when I navigate into a child view, a dynamic view embedded within a custom SwiftUI element I created entitled a CategoryRow.
I've got it working, sort of, where the header disappears upon navigation into NavigationLink that I have embedded within the CategoryRow. Only problem is, the response time is a bit slow, and it seems as if the onDisappear event within the NavigationLink only fires sometimes. Here's a gif that demonstrates the exact behavior I am facing:
Here's some of the relevant code I have within my parent view, which I have entitled FeedView:
#State private var showHeader = true // showHeader will be a state variable passed between the parent and child in order to show/hide the header
var body: some View {
VStack(spacing: 0) {
if showHeader { // update and show/hide the header embedded in the HStack on state change
HStack(spacing: 0) {
// header
}
}
NavigationView {
List(categories.keys.sorted(), id: \String.self) {
// we pass the state variable over to the CategoryRow class to be used in appear/disappear
key in CategoryRow(nameOfCategory: "\(key)".uppercased(), posts: self.posts[key]!, showHeader: self.$showHeader)
}
}
(Child View; a CategoryRow element):
#Binding var showHeader: Bool // bind the passed in showHeader variable so we can pass it back to the parent when updates need to happen
// ...
NavigationLink(destination: ExpandMediaView(post: post)
.onAppear { self.showHeader = false } // after testing, this onAppear ALWAYS manages to fire
.onDisappear { self.showHeader = true } // why does this only fire sometimes?
)
The code only hits the .onDisappear portion of the NavigationLink sometimes, which means sometimes the header reappears on the parent class, and sometimes it doesn't. On top of this, the show/hiding of it is about a millisecond behind the page navigation. Has anyone worked through an issue like this before?

Related

Call view modifying function on child view

I have a view which has a function fadeInOut() that fades a square in and out.
struct FadingSquare: View {
#State var fading = false
var body: some View {
Rectangle()
.frame(width: 20, height: 20)
.foregroundColor(.primary)
.opacity(fading ? 1 : 0)
.onAppear {
fadeInOut()
}
}
func fadeInOut() {
withAnimation(.linear(duration: 1)) {
self.fading = true
}
withAnimation(.linear(duration: 1).delay(1)) {
self.fading = false
}
}
}
That works fine, the square fades in, then fades out.
But I want to place FadingSquare in other places, and call fadingSquare.fadeInOut() from a parent view. This is what I have attempted:
struct ContentView: View {
var fadingSquare = FadingSquare()
var body: some View {
VStack {
fadingSquare
Button {
fadingSquare.fadeInOut()
} label: {
Text("Fade")
}
}
}
}
This does not work, and making fadingSquare a #State doesn't work either. I'd rather not use Bindings, I want the FadingSquare to be responsible for its own values.
So in SwiftUI, parents can modify their children in really just two ways.
Within the parent view, (ContentView) add a #State variable to track the fade. Within the child view, (FadingSquare) change the #State variable to #Binding and remove the default value. Now, within ContentView, you can have the square update when the parent's fade variable changes by writing it in the body as FadingSquare(fading: $ourFadingState) where ourFadingState is that #State variable we created.
The parent and the child each hold a reference to the same ObservableObject. This is the most performant way to do it when dealing with complex UIs. Within the parent's button action, you'd modify the object's fading property. Within the FadingSquare, you'd have an #ObservedObject variable which would cause the square to update itself upon any changes, regardless of if they were made within the view itself, a parent, or on the moon.
By the way, for SwiftUI to work properly, you don't keep a fadingView property, you instantiate a brand new one within the body code. This is because when it's a property, it can't update until the parent gets trashed, which will never happen because ContentView is typically the root view of a scene and will never get destroyed. Look at the system SwiftUI views and notice you don't need to hold them within variables either. We do it the same way.
So instead of:
var fadingSquare = FadingSquare()
var body: some View {
fadingSquare
}
It should be:
var body: some View {
FadingSquare()
}
Remember that unlike UIKit, a SwiftUI view struct is not the view itself. It is a collection of data describing to the system what the view should contain during a given refresh, like HTML. So initializing a new FadingSquare() within body code is correct and it will be the same view, even if it is "a new" struct in code.

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.

Navigation between SwiftUI Views

I don't know how to navigate between views with buttons.
The only thing I've found online is detail view, but I don't want a back button in the top left corner. I want two independent views connected via two buttons one on the first and one on the second.
In addition, if I were to delete the button on the second view, I should be stuck there, with the only option to going back to the first view being crashing the app.
In storyboard I would just create a button with the action TouchUpInSide() and point to the preferred view controller.
Also do you think getting into SwiftUI is worth it when you are used to storyboard?
One of the solutions is to have a #Statevariable in the main view. This view will display one of the child views depending on the value of the #Statevariable:
struct ContentView: View {
#State var showView1 = false
var body: some View {
VStack {
if showView1 {
SomeView(showView: $showView1)
.background(Color.red)
} else {
SomeView(showView: $showView1)
.background(Color.green)
}
}
}
}
And you pass this variable to its child views where you can modify it:
struct SomeView: View {
#Binding var showView: Bool
var body: some View {
Button(action: {
self.showView.toggle()
}) {
Text("Switch View")
}
}
}
If you want to have more than two views you can make #State var showView1 to be an enum instead of a Bool.

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