Run action when view is 'removed' - swift

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.

Related

SwiftUI: resetting TabView

I have a TabView with two tabs in a SwiftUI lifecycle app, one of them has complex view structure: NavigationView with a lot of sub-views inside, i.e.: NavigationLinks and their DestinationViews are spread on multiple levels down the view tree, each sub-view on its own is another view hierarchy with sheets and / or other DestinationViews. At some point inside this hierarchy, I want to reset the TabView to its original state which is displaying the first most view, so the user can restart their journey right at that state, as they were to open the app for the first time, so it's kinda impossible to track down something like isActive & isPresented bindings to pop-off or dismiss the views and sheets.
I thought of wrapping the TabView inside another view: RootView in an attempt to find an easy way to recreate that TabView from scratch or something like refreshing / resetting the TabView, but couldn't find a clew on how to do it.
Here's my code snippet:
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}
struct RootView: View {
var body: some View {
ContentView()
}
}
struct ContentView: View {
var body: some View {
TabView { // <-- I need to reset it to its original state
View1() // <---- this view has complex view hierarchy
.tabItem {
Text("Home")
}.tag(0)
View2()
.tabItem {
Text("Settings")
}.tag(1)
}
}
}
p.s. I'm not looking for "popping off the view to root view", because this can't be done when there are many active NavigationLink destinations where the user might open one of the sheets and start a new navigation journey inside the sheet.
****** UPDATE ******
I've created a new Environment value to hold a boolean that should indicate whether the TabView should reset or not, and I've tracked every isPresented and isActive state variables in every view and reset them once that environment value is set to true like this:
struct ResetTabView: EnvironmentKey {
static var defaultValue: Binding<ResetTabObservable> = .constant(ResetTabObservable())
}
extension EnvironmentValues {
var resetTabView: Binding<ResetTabObservable> {
get { self[ResetTabView.self] }
set { self[ResetTabView.self] = newValue }
}
}
class ResetTabObservable: ObservableObject {
#Published var newValue = false
}
in every view that will present a sheet or push a new view I added something like this:
struct View3: View {
#State var showSheet = false
#Environment(\.resetTabView) var reset
var body: some View {
Text("This is view 3")
Button(action: {
showSheet = true
}, label: {
Text("show view 4")
})
.sheet(isPresented: $showSheet) {
View4()
}
.onReceive(reset.$newValue.wrappedValue, perform: { val in
if val == true {
showSheet = false
}
})
}
}
and in the last view (which will reset the TabView) I toggle the Environment value like this:
struct View5: View {
#Environment(\.resetTabView) var reset
var body: some View {
VStack {
Text("This is view 5")
Button(action: {
reset.newValue.wrappedValue = true
}, label: {
Text("reset tab view")
})
}
}
}
This resulted in awkward dismissal for views:
What i do for this is i make all my presentation bindings be stored using #SceneStorage("key") (instead of #State) this way they not only respect state restoration ! but you can also access them throughout your app easily by using the same key. This post gives a good example of how this enables the switching from Tab to Sidebar view on iPad.
I used this in my apps so if i have a button or something that needs to unwind many presentations it can read on all of these values and reset them back to wanted value without needing to pass around a load of bindings.

Refresh view when navigating to it

In SwiftUI 2, when I navigate from a parent view to a child view and then back to the parent view, the parent view does not refresh its contents. How can I force it to refresh its contents?
In order to test if the contents get refreshed, in my parent view I displayed a random number using the following code:
Text("Random number is \(Int.random(in: 1..<100))")
When I navigate to a child view, and then I tap the Back button in the navigation bar to return to this parent view, the random number displayed remains the same. This indicates that the view is not refreshing.
How can I force the view to refresh itself whenever the user navigates back to it?
You could force SwiftUI to update the list by adding an .id(viewID) view modifier to the source view with an #State variable, in this case called "viewID". Then update this viewID in .onDisappear() of the destination view:
struct ContentView: View {
#State private var viewID: Int = 0
var body: some View {
NavigationView {
VStack {
Text("Hello, random world number \(Int.random(in: 1...100))")
.padding()
.id(viewID)
NavigationLink(
destination: destinationView,
label: { labelView })
}
}
}
private var labelView: some View {
Text("Go to Destination View")
}
private var destinationView: some View {
return Text("I am the Destination.")
.onDisappear{
viewID += 1
}
}
}
SwiftUI is declarative - as in you define the states the view could be in and Swift UI will take care of when it should update. In your example, there is no change in state, and thus, the view doesn't update.
If you want the view to refresh, you need to update state on appear events. You can do so like this:
struct ContentView: View {
#State var intValue: Int = 0
var body: some View {
Text("Random number is \(self.intValue)")
.onAppear {
intValue = Int.random(in: 1..<100)
}
}
}
You'll find that if you push a different view using a NavigationView/NavigationLink, and then navigate back, the label should update with a new random value.

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.

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

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