SwiftUI: resetting TabView - swift

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.

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.

Why does my SwiftUI View not update on updating of an #State var?

I am having a strange issue with an #State var not updating an iOS SwiftUI view.
I have an edit screen for themes for a small game with a NavigationView with a list of game themes. When in edit mode and I select one of these themes, I open up an editor view, passing the theme as a binding to the editor view struct.
In my editor view I then have sections that allow the user to edit properties of the theme. I do not want to use bindings to the various theme properties in my edit fields because I do not want the changes to take effect immediately. Instead, I have created #State vars for each of these properties and then use bindings to these in the edit fields. That way, I give the user the option to either cancel without and changes taking effect, or select "Done" to assign the changes back to the theme via the binding.
In order to initialise the #State vars I have an onAppear block that assign the #State vars values from the respective theme properties.
The issue I am having is that when the onAppear block is executed and the vars are assigned, the relevant edit fields are not updating!
Here is a cut-down version of my code:
struct EditorView: View {
/// The current presentation mode of the view.
#Environment(\.presentationMode) var presentationMode
#Binding var theme: GameTheme
#State private var name = ""
...
var body: some View {
NavigationView {
Form {
nameSection
...
}
.navigationTitle("Edit \(theme.name)")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", action: cancel)
}
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: saveTheme)
.disabled(!canSaveTheme)
}
}
.onAppear {
name = theme.name
...
}
}
.frame(minWidth: Constants.minViewSize.width, minHeight: Constants.minViewSize.height)
}
var nameSection: some View {
Section(header: Text("Name")) {
TextField(LocalizedStringKey("Name"), text: $name)
}
}
...
}
So the view gets shown an on appearing, the #State var name does correctly get assigned the value from theme.name; however, this allocation does not cause an update of the view and the value of "name" is not entered into the TextField.
Interestingly, and I do not know if this is a good thing to do, if I wrap the contents of the onAppear block in a DispatchQueue.main.async, everything works fine!
i.e.
.onAppear {
DispatchQueue.main.async {
name = theme.name
...
}
}
Does anyone have any idea as to how, within the onAppear, I can force a view refresh? Or, why the assignment to "name" does not force an update?
Thanks.
This isn't the answer per se, but I went ahead and created a new iOS project with the following code (based on your post, but I cleaned it up a bit and came up with the missing GameTheme object myself).
It's more or less the same, and shows that your posted structure does re-render.
I'm wondering if there's more to the code we can't see in your post that could be causing this.
Are you possibly setting the name state variable anywhere else in a way that could be overriding the value on load?
import SwiftUI
#main
struct TestIOSApp: App {
#State var gameTheme: GameTheme = GameTheme(name: "A game theme")
var body: some Scene {
WindowGroup {
ContentView(theme: $gameTheme)
}
}
}
struct GameTheme {
var name:String;
}
struct ContentView: View {
#Binding var theme:GameTheme;
/// The current presentation mode of the view.
#Environment(\.presentationMode) var presentationMode
#State private var name = "DEFAULT SHOULD NOT BE DISPLAYED"
var body: some View {
NavigationView {
Form {
nameSection
}
.navigationTitle("Edit \(theme.name)")
.onAppear {
name = theme.name
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", action: {})
}
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: {})
}
}
.frame(maxWidth:.infinity, maxHeight: .infinity)
}
var nameSection: some View {
Section(header: Text("Name")) {
TextField(LocalizedStringKey("Name"), text: $name)
}
}
}
I seem to have solved my problem with an init(). I created init(theme: Binding<GameTheme>) and then within the init assigned the theme via _theme = theme and then assigned the name via _name = State(initialValue: theme.name.wrappedValue).

SwiftUI - Conditional based on ObservedObject doesn't work in subsequent Views

I have a very simple app which contains two views that are tied together with a NavigationLink. In the first view, ContentView, I can see updates to my ObservedObject as expected. However, when I go to the next View, it seems that the code based on the ObservedObject does not recognize changes.
ContentView.swift (The working view):
import SwiftUI
struct ContentView: View {
#ObservedObject var toggleObject = ToggleObject()
var body: some View {
NavigationView {
VStack(spacing: 15) {
Toggle(isOn: self.$toggleObject.isToggled) {
Text("Toggle:")
}
if self.toggleObject.isToggled == true {
Text("ON")
}
else {
Text("OFF")
}
NavigationLink(destination: ShowToggleView()) {
Text("Show Toggle Status")
}
}
}
}
}
ShowToggleView.swift (The view that does not behave as I expect it to):
import SwiftUI
struct ShowToggleView: View {
#ObservedObject var toggleObject = ToggleObject()
var body: some View {
Form {
Section {
if self.toggleObject.isToggled {
Text("Toggled on")
}
else {
Text("Toggled off")
}
}
}
}
}
All of this data is stored in a simple file, ToggleObject.swift:
import SwiftUI
class ToggleObject: ObservableObject {
#Published var isToggled = false
}
When I toggle it on in the first View I see the text "ON" which is expected, but when I go into the next view it shows "Toggled off" no matter what I do in the first View... Why is that?
Using Xcode 11.5 and Swift 5
You are almost doing everything correct. However, you are creating another instance of ToggleObject() in your second view, which overrides the data. You basically only create one ObservedObject and then pass it to your subview, so they both access the same data.
Change it to this:
struct ShowToggleView: View {
#ObservedObject var toggleObject : ToggleObject
And then pass the object to that view in your navigation link...
NavigationLink(destination: ShowToggleView(toggleObject: self.toggleObject)) {
Text("Show Toggle Status")
}

SwiftUI: Dismiss View Within macOS NavigationView

As detailed here (on an iOS topic), the following code can be used to make a SwiftUI View dismiss itself:
#Environment(\.presentationMode) var presentationMode
// ...
presentationMode.wrappedValue.dismiss()
However, this approach doesn't work for a native (not Catalyst) macOS NavigationView setup (such as the below), where the selected view is displayed alongside the List.
Ideally, when any of these sub-views use the above, the list would go back to having nothing selected (like when it first launched); however, the dismiss function appears to do nothing: the view remains exactly the same.
Is this a bug, or expected macOS behaviour?
Is there another approach that can be used instead?
struct HelpView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination:
AboutAppView()
) {
Text("About this App")
}
NavigationLink(destination:
Text("Here’s a User Guide")
) {
Text("User Guide")
}
}
}
}
}
struct AboutAppView: View {
#Environment(\.presentationMode) var presentationMode
public var body: some View {
Button(action: {
self.dismissSelf()
}) {
Text("Dismiss Me!")
}
}
private func dismissSelf() {
presentationMode.wrappedValue.dismiss()
}
}
FYI: The real intent is for less direct scenarios (such as triggering from an Alert upon completion of a task); the button setup here is just for simplicity.
The solution here is simple. Do not use Navigation View where you need to dismiss the view.
Check the example given by Apple https://developer.apple.com/tutorials/swiftui/creating-a-macos-app
If you need dismissable view, there is 2 way.
Create a new modal window (This is more complicated)
Use sheet.
Following is implimenation fo sheet in macOS with SwiftUI
struct HelpView: View {
#State private var showModal = false
var body: some View {
NavigationView {
List {
NavigationLink(destination:
VStack {
Button("About"){ self.showModal.toggle() }
Text("Here’s a User Guide")
}
) {
Text("User Guide")
}
}
}
.sheet(isPresented: $showModal) {
AboutAppView(showModal: self.$showModal)
}
}
}
struct AboutAppView: View {
#Binding var showModal: Bool
public var body: some View {
Button(action: {
self.showModal.toggle()
}) {
Text("Dismiss Me!")
}
}
}
There is also a 3rd option to use ZStack to create a Modal Card in RootView and change opacity to show and hide with dynamic data.

Removing large amounts of whitespace in a SwiftUI subview

Demonstration of whitespace problem
When I nest a NavigationView within a NavigationView, an enormous amount of whitespace separates the back button and the new navigation bar title. Is there something I'm doing wrong in terms of setting up my SwiftUI views?
import SwiftUI
struct Dashboard: View {
#EnvironmentObject var user: User
let courses = Course.exampleCourses()
var body: some View {
NavigationView {
List(courses) { course in
NavigationLink(destination: CourseView(course: course)) {
Text(course.name)
}
}.navigationBarTitle("Welcome, \(user.first)!")
}
}
}
import SwiftUI
struct CourseView: View {
// #ObservedObject allows us to update views whenever values in course change
#ObservedObject var course: Course
#EnvironmentObject var user: User
var body: some View {
NavigationView {
List {
NavigationLink(destination: WritingPromptView(prompt: "What is your course goal, \(user.first)?", explanationText: "This is the answer", textLocation: self.$course.goal)) {
Text("Course Goal")
}
NavigationLink(destination: NotepadView(parent: self.course)) {
Text("Notepad")
}
NavigationLink(destination: WritingPromptView(prompt: "<Reflection prompt goes here>", explanationText: "<How to reflect goes here>", textLocation: self.$course.reflection)) {
Text("Reflection")
}
}.navigationBarTitle(course.name)
}
}
}
It's a double NavigationBar. Just remove NavigationView from your CourseView. If you have Previews for CourseView, you will probably want to wrap it NavigationView there.