SwiftUI - Dismissing a view, app goes back to content view instead of last view - ios

I have 3 views within my app where clicking on a button on each view opens a new view. When button is clicked on 3rd view, I wish to dismiss 3rd view and 2nd view should appear. However I am noticing that app navigates back to first view instead of 2nd view.
Note: I have lots of elements, hence lots of code in my app. I removed all of them and adding minimal working code here with which I am still able to repro the problem.
// *** Main App***
#main
struct sample_sampleApp: App {
var body: some SwiftUI.Scene {
WindowGroup {
NavigationView {
ContentView().ignoresSafeArea()
}.navigationViewStyle(.stack)
}
}
}
// *** Content View or First View***
import SwiftUI
struct ContentView: View {
#State private var goToView2 = false
var body: some View {
VStack {
NavigationLink(destination: View2(), isActive: $goToView2) {
Button(action: { goToView2.toggle() }) {
Text("This is first view - Click to go to View 2").foregroundColor(.red).font(.title)
}
}
}
}
}
// *** View2 View***
import SwiftUI
import Combine
struct View2: View {
#ObservedObject var viewModel: View2ViewModel = View2ViewModel()
var body: some View {
VStack {
switch viewModel.state {
case .showView2:
VStack(alignment: .leading, spacing: 8) {
Button(action: { viewModel.navigateToView3() } ) {
Text("Second View - Click to go to View 3").foregroundColor(.blue).font(.title)
}
}
case .showView3:
View3()
}
}
.onAppear() {
viewModel.isViewVisible = true
viewModel.doSomething()
}
.onDisappear() {
viewModel.isViewVisible = false
}
}
}
// *** View model for view 2***
class View2ViewModel: ObservableObject {
enum View2AppState {
case showView2
case showView3
}
// UI changes when state gets updated.
#Published var state: View2AppState = .showView2
var isViewVisible = false
func doSomething() {
self.state = .showView2
}
func navigateToView3() {
self.state = .showView3
}
}
// *** View3***
struct View3: View {
#ObservedObject var viewModel: View3ViewModel = View3ViewModel()
#Environment(\.dismiss) var dismiss
var body: some View {
VStack {
switch viewModel.state {
case .showView3:
VStack(alignment: .leading, spacing: 8) {
Button(action: { dismiss() } ) {
Text("Third View - Click to dismiss this and to go back to view 2").foregroundColor(.green).font(.title)
}
}
}
}
.onAppear() {
viewModel.isViewVisible = true
viewModel.doSomething()
}
.onDisappear() {
viewModel.isViewVisible = false
}.navigationBarBackButtonHidden(true)
}
}
// *** View model for view 3***
class View3ViewModel: ObservableObject {
enum View3AppState {
case showView3
}
// UI changes when state gets updated.
#Published var state: View3AppState = .showView3
var isViewVisible = false
func doSomething() {
self.state = .showView3
}
}
Not sure what am I doing wrong. Sometime back I did use dismiss() while dismissing sheet and it worked fine, but not this this case. I am running this code on iOS 16.0, however my test app is set to iOS 15 as minimum version.
Edit: I tested on iOS 15.0 as well and was able to repro on it too, so something must be wrong with my code then. Not able to figure out what. I am using NavigationView in and navigation view style as Stack.

When button is clicked on 3rd view, I wish to dismiss 3rd view and 2nd view should appear.
Let's first have a look at the code of view2.
struct View2: View {
#ObservedObject var viewModel: View2ViewModel = View2ViewModel()
var body: some View {
VStack {
switch viewModel.state {
case .showView2:
VStack(alignment: .leading, spacing: 8) {
Button(action: { viewModel.navigateToView3() } ) {
Text("Second View - Click to go to View 3").foregroundColor(.blue).font(.title)
}
}
case .showView3:
View3()
}
}
}
}
// here viewModel.navigateToView3() is just changing this state
// func navigateToView3() {
// self.state = .showView3
// }
The current code behavior, when tapping to navigate to view3, replaces the content of view2 with view3 instead of actually navigating to it.
Therefore, when the dismiss function is called, it should not return to view2 as it is already in view2 displaying the content of view3.
So, going back to view1 on the dismiss press is actually the correct behavior as per the code.
If you desire the outcome you are asking, consider modifying the code using a closure passed into the child view to change the state in view2 or explore this answer to actually navigate to it.

Related

Multiple back buttons created when navigates to root screen in SwiftUI

say I create 3 screens in SwiftUI which contains a NavigationLink to the next screen. like, first screen navigates to 2nd screen. 2nd screen navigates to third. and the third screen navigates to the first screen. In this case even if I use NavigationView only once(in the first screen). I encountered that there's a back button forming when I navigate to 1st screen from the third screen. And it keeps adding up when I start to navigate from then on. I have tried to use .navigationBarBackButtonHidden(true). It hides it but the space taken by back button was still there.
My code is similar to this:
struct FirstScreen: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: SecondScreen()) {
Text("Go to Second Screen")
}
}
}
}
}
struct SecondScreen: View {
var body: some View {
VStack {
NavigationLink(destination: ThirdScreen()) {
Text("Go to Third Screen")
}
}
}
}
struct ThirdScreen: View {
var body: some View {
VStack {
NavigationLink(destination: FirstScreen()) {
Text("Go to First Screen")
}
}
}
}
this is the image
You're pushing the FirstScreen onto your navigation stack, but FirstScreen contains its own NavigationView. If you really want to keep pushing them on the stack, then move the NavigationView outside of FirstScreen.
struct ContentView: View {
var body: some View {
NavigationStack { // Use NavigationStack for iOS 16
FirstScreen()
}
}
}
struct FirstScreen: View {
var body: some View {
VStack {
NavigationLink(destination: SecondScreen()) {
Text("Go to Second Screen")
}
}
}
}
If you actually want to pop all the views off the stack and go back to FirstScreen you should use
init(path: Binding<NavigationPath>, etc)
Here's a simple example passing the path down the stack and resetting it to pop back to the root…
enum Screen {
case two, three
}
struct ContentView: View {
#State var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
VStack {
// value gets appended to path
NavigationLink("Go to Second Screen", value: Screen.two)
}
// Decides which screen to show for values in path
.navigationDestination(for: Screen.self) { index in
switch index {
case .two:
SecondScreen(path: $path)
case .three:
ThirdScreen(path: $path)
}
}
}
}
}
struct SecondScreen: View {
#Binding var path: NavigationPath
var body: some View {
VStack {
NavigationLink("Go to Third Screen", value: Screen.three)
}
}
}
struct ThirdScreen: View {
#Binding var path: NavigationPath
var body: some View {
VStack {
Button("Go to First Screen") {
// reset the path to pop to root
path = NavigationPath()
}
}
}
}

SwiftUI auto navigates to detail view after saving and dismissing the view

So I am writing a todo list app in SwiftUI in order to get the hang of it, but I am facing a problem.
In my first view (list of items) I have a toolbar with an "add" button which uses a NavigationLink to navigate to the detail view. In the detail view I also have a toolbar button acting as a save button which dismisses this view and also adds the item to a list of items kept in the view model used by both views.
The problem is that if I save the item when tapping the save button it will first navigate back to the first view and then auto navigate to the second view again. If I instead use the built in back button this issue doesn't happen, but obviously I would like to save the item and only when pressing save. This also only happens if I add the item to the item list in the view model before dismissing the view, if I only dismiss the view without saving the item when pressing done then this bug doesn't happen.
Is this not a standard way of saving and closing a view with SwiftUI, or is there some sort of other pattern that is better? In any case I need to resolve this issue.
First view:
struct TodoListView: View {
#EnvironmentObject var viewModel: TodoListViewModel
var body: some View {
NavigationView {
List {
ForEach(viewModel.listOfTodos) { todoItem in
ItemCellView(todoItem: todoItem)
}
}
.navigationTitle("Things to do")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink(
destination: AddEditTodoView(todoItem: TodoListInfo.TodoItem())
) {
Text("Add item") // The navigation bug happens when using this button
}
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ItemCellView: View {
var todoItem: TodoListInfo.TodoItem
var body: some View {
HStack {
NavigationLink(destination: AddEditTodoView(todoItem: todoItem)) {
Text(todoItem.title) // The navigation bug doesn't happen when editing an existing item
}
}
.padding()
}
}
Second view:
struct AddEditTodoView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#EnvironmentObject var viewModel: TodoListViewModel
#State var todoItem: TodoListInfo.TodoItem
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("Title", text: $todoItem.title)
}
}
.navigationTitle(Text("Edit task"))
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
viewModel.upsert(item: todoItem) // No bug if I comment out this line
presentationMode.wrappedValue.dismiss()
}
.disabled(todoItem.title == "")
}
}
}
}
View model:
class TodoListViewModel: ObservableObject {
#Published private var todoListInfo: TodoListInfo
private var autoSaveCancellable: AnyCancellable? // even without the autoSaveCancellable part, the bug happens
init(testData: Bool = false) {
todoListInfo = TodoListInfo(testData: testData)
autoSaveCancellable = $todoListInfo.sink {
TodoListInfo.persistTodoList($0)
}
}
var listOfTodos: [TodoListInfo.TodoItem] {
todoListInfo.todos
}
func upsert(item: TodoListInfo.TodoItem) {
if let itemIndex = todoListInfo.todos.firstIndex(where: { $0.id == item.id }) {
todoListInfo.todos[itemIndex] = item
} else {
todoListInfo.todos.append(item) // This gets called when adding an item
}
}
}
The solution for this is to make the navigation be based on a binding state.
NavigationLink(
destination: ExchangeItemSelectedView(exchange: observer),
tag: exchange.id,
selection: $exchangeSelection
) {
Text("Tap Me")
}
then rather than using #State to store exchangeSelection use #SceneStorage this will let you access the binding from anywhere within your app, in the code that creates the new item it should then dispatch async to update the selection value to the new item ID.

How it is possible to dismiss a view from a subtracted subview in SwiftUI

Whenever my code gets too big, SwiftUI starts acting weird and generates an error:
"The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions"
So I started breaking up my code into Extracted Subviews, one of the problems I came across is how to dismiss a view from a subtracted subview.
Example: we have here LoginContentView this view contains a button when the button is clicked it will show the next view UsersOnlineView.
struct LoginContentView: View {
#State var showUsersOnlineView = false
var body: some View {
Button(action: {
self.showUsersOnlineView = true
}) {
Text("Show the next view")
}
.fullScreenCover(isPresented: $showUsersOnlineView, content: {
UsersOnlineView()
})
}
On the other hand, we have a button that is extracted to subview, to dismiss the modal and go back to the original view:
import SwiftUI
struct UsersOnlineView: View {
var body: some View {
ZStack {
VStack {
CloseViewButton()
}
}
}
}
struct CloseViewButton: View {
var body: some View {
Button(action: {
// Close the Modal
}) {
Text("Close the view")
}
}
}
Give the sbview the state property that defines if the view is shown.
struct CloseViewButton: View {
#Binding var showView: Bool
var body: some View {
Button(
ShowView = false
}) {
Text("Close the view")
}
}
}
When you use the sub view give it the property
CloseButtonView(showView: $showOnlineView)
To allow the sub view to change the isShown property it needs to get a binding.
On the presentation mode. I think this only works with Swiftui presentations like sheet and alert.
The simplest solution for this scenario is to use presentationMode environment variable:
struct CloseViewButton: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Button(action: {
presentationMode.wrappedValue.dismiss()
}) {
Text("Close the view")
}
}
}
Tested with Xcode 12.1 / iOS 14.1

Child subviews not resetting when a new parent is created in SwiftUI

I made a post about this yesterday and apologize for it not being clear or descriptive enough. Today I've made some more progress on the problem but still haven't found a solution.
In my program I have a main view called GameView(), a view called KeyboardView() and a view called ButtonView().
A ButtonView() is a simple button that displays a letter and when pressed tells the keyboardView it belongs to what letter it represents. When it's pressed it is also toggled off so that it cannot be pressed again. Here is the code.
struct ButtonView: View {
let impactFeedbackgenerator = UIImpactFeedbackGenerator(style: .medium)
var letter:String
var function:() -> Void
#State var pressed = false
var body: some View {
ZStack{
Button(action: {
if !self.pressed {
self.pressed = true
self.impactFeedbackgenerator.prepare()
self.impactFeedbackgenerator.impactOccurred()
self.function()
}
}, label: {
if pressed{
Text(letter)
.font(Font.custom("ComicNeue-Bold", size: 30))
.foregroundColor(.white)
.opacity(0.23)
} else if !pressed{
Text(letter)
.font(Font.custom("ComicNeue-Bold", size: 30))
.foregroundColor(.white)
}
})
}.padding(5)
}
}
A keyboard view is a collection of ButtonViews(), one for each button on the keyboard. It tells the GameView what button has been pressed if a button is pressed.
struct KeyboardView: View {
#Environment(\.parentFunction) var parentFunction
var topRow = ["Q","W","E","R","T","Y","U","I","O","P"]
var midRow = ["A","S","D","F","G","H","J","K","L"]
var botRow = ["Z","X","C","V","B","N","M"]
var body: some View {
VStack{
HStack(){
ForEach(0..<topRow.count, id: \.self){i in
ButtonView(letter: self.topRow[i], function: {self.makeGuess(self.topRow[i])})
}
}
HStack(){
ForEach(0..<midRow.count, id: \.self){i in
ButtonView(letter: self.midRow[i], function: {self.makeGuess(self.midRow[i])})
}
}
HStack(){
ForEach(0..<botRow.count, id: \.self){i in
ButtonView(letter: self.botRow[i], function: {self.makeGuess(self.botRow[i])})
}
}
}
}
func makeGuess(_ letter:String){
print("Keyboard: Guessed \(letter)")
self.parentFunction?(letter)
}
}
Finally a GameView() is where the keyboard belongs. It displays the keyboard along with the rest of the supposed game.
struct GameView: View {
#Environment(\.presentationMode) var presentation
#State var guessedLetters = [String]()
#State var myKeyboard:KeyboardView = KeyboardView()
var body: some View {
ZStack(){
Image("Background")
.resizable()
.edgesIgnoringSafeArea(.all)
.opacity(0.05)
VStack{
Button("New Game") {
self.newGame()
}.font(Font.custom("ComicNeue-Bold", size: 20))
.foregroundColor(.white)
.padding()
self.myKeyboard
.padding(.bottom, 20)
}
}.navigationBarTitle("")
.navigationBarBackButtonHidden(true)
.navigationBarHidden(true)
.environment(\.parentFunction, parentFunction)
}
func makeGuess(_ letter:String){
self.guessedLetters.append(letter)
}
func newGame(){
print("Started a new game.")
self.guessedLetters.removeAll()
self.myKeyboard = KeyboardView()
}
func parentFunction(_ letter:String) {
makeGuess(letter)
}
}
struct ParentFunctionKey: EnvironmentKey {
static let defaultValue: ((_ letter:String) -> Void)? = nil
}
extension EnvironmentValues {
var parentFunction: ((_ letter:String) -> Void)? {
get { self[ParentFunctionKey.self] }
set { self[ParentFunctionKey.self] = newValue }
}
}
The issue is that when I start a new game, the array is reset but not keyboardView(), the buttons that have been toggled off remain off, but since it's being replaced by a new keyboardView() shouldn't they go back to being toggled on?
I'll repeat what I said in an answer to your previous question - under most normal use cases you shouldn't instantiate views as variables, so if you find yourself doing that, you might be on the wrong track.
Whenever there's any state change, SwiftUI recomputes the body and reconstructs the view tree, and matches the child view states to the new tree.
When it detects that something has changed, it realizes that the new child view is truly new, so it resets its state, fires .onAppear and so forth. But when there's no change that it can detect, then it just keeps the same state for all the descendent views.
That's what you're observing.
Specifically, in your situation nothing structurally has changed - i.e. it's still:
GameView
--> KeyboardView
--> ButtonView
ButtonView
ButtonView
...
so, it keeps the state of ButtonViews as is.
You can signal to SwiftUI that the view has actually changed and that it should be updated by using an .id modifier (documentation isn't great, but you can find more info in blogs), where you need to supply any Hashable variable to it that's different than the current one, in order to reset it.
I'll use a new Bool variable as an example:
struct GameView {
#State var id: Bool = false // initial value doesn't matter
var body: some View {
VStack() {
KeyboardView()
.id(id) // use the id here
Button("new game") {
self.id.toggle() // changes the id
}
}
}
}
Every time the id changes, SwiftUI resets the state, so all the child views', like ButtonViews', states are reset.

PresentationLink always use the same destination object

I was surprised that whenever I tap on the button embedded in PresentationLink, the same view reference shows up all the time. What I mean is that we don't create another instance of the view.
This makes sense as the destination object is created within the body property and will therefore not be recreated unless a change occur.
Do you guys know if we have a trivial way to recreate a new view every time we hit the button? Or is it by design and should be use like this?
Thank you!
EDIT
After #dfd 's comment, it seems to be by designed.
Now how to handle this use case:
Let's say I present a NavigationView and I pushed one view. If I
dismiss and re present, I will go back on the view I previously
pushed. In this case, I believe it's wrong as I'd like the user to go
through the complete flow every single time. How can I make sure that
I go back on the first screen everytime?
Thank you (again)!
EDIT 2
Here's some code:
struct PresenterExample : View {
var body: some View {
VStack {
PresentationLink(destination: CandidateCreateProfileJobView()) {
Text("Present")
}
}
}
}
struct StackFirstView : View {
var body: some View {
NavigationLink(destination: StackSecondView()) {
Text("Got to view 2")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
In this case that would be PresenterExample presents StackFirstView which will push StackSecondView from the NavigationLink.
From there, let's say the user swipe down and therefore dismiss the presentation. When it clicks back on the PresentationLink in PresenterExample it will open back on StackSecondView, which is not what I want. I want to display StackFirstView again.
Makes more sense? :)
First Try: Failure
I tried using the id modifier to tell SwiftUI to treat each presentation of StackFirstView as a completely new view unrelated to prior views:
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
PresentationLink("Present", destination:
StackFirstView()
.onDisappear {
print("onDisappear")
self.presentationCount += 1
}
)
}
}
#State private var presentationCount = 0
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
.navigationBarTitle("StackSecondView")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This should do the following:
It should identify the StackFirstView by presentationCount. SwiftUI should consider each StackFirstView with a different identifier to be a completely different view. I've used this successfully with animated transitions.
It should increment presentationCount when the StackFirstView is dismissed, so that the next StackFirstView gets a different identifier.
The problem is that SwiftUI never calls the onDisappear closure for the presented view or any of its subviews. I'm pretty sure this is a SwiftUI bug (as of Xcode 11 beta 3). I filed FB6687752.
Second Try: FailureSuccess
Next I tried managing the presentation myself, using the presentation(Modal?) modifier, so I wouldn't need the onDisappear modifier:
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
Button("Present") {
self.presentModal()
}.presentation(modal)
}
}
#State private var shouldPresent = false
#State private var presentationCount = 0
private func presentModal() {
presentationCount += 1
shouldPresent = true
}
private var modal: Modal? {
guard shouldPresent else { return nil }
return Modal(StackFirstView().id(presentationCount), onDismiss: { self.shouldPresent = false })
}
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This fails in a different way. The second and later presentations of StackFirstView simply present a blank view instead. Again, I'm pretty sure this is a SwiftUI bug. I filed FB6687804.
I tried passing the presentationCount down to StackFirstView and then applying the .id(presentationCount) modifier to the NavigationView's content. That crashes the playground if the modal is dismissed and presented again while showing StackSecondView. I filed FB6687850.
Update
This tweet from Ryan Ashcraft showed me a workaround that gets this second attempt working. It wraps the Modal's content in a Group, and applies the id modifier to the Group's content:
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
Button("Present") {
self.presentModal()
}.presentation(modal)
}
}
#State private var shouldPresent = false
#State private var presentationCount = 0
private func presentModal() {
presentationCount += 1
shouldPresent = true
}
private var modal: Modal? {
guard shouldPresent else { return nil }
return Modal(Group { StackFirstView().id(presentationCount) }, onDismiss: { self.shouldPresent = false })
}
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This revised second try successfully resets the state of the Modal on each presentation. Note that the id must be applied to the content of the Group, not to the Group itself, to work around the SwiftUI bug.
Third Try: Success
I modified the second try so that, instead of using the id modifier, it wraps the StackFirstView inside a ZStack when presentationCount is an odd number.
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
Button("Present") {
self.presentModal()
}.presentation(modal)
}
}
#State private var shouldPresent = false
#State private var presentationCount = 0
private func presentModal() {
presentationCount += 1
shouldPresent = true
}
private var modal: Modal? {
guard shouldPresent else { return nil }
if presentationCount.isMultiple(of: 2) {
return Modal(presentationContent, onDismiss: { self.shouldPresent = false })
} else {
return Modal(ZStack { presentationContent }, onDismiss: { self.shouldPresent = false })
}
}
private var presentationContent: some View {
StackFirstView()
}
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This works. I guess SwiftUI sees that the modal's content is a different type each time (StackFirstView vs. ZStack<StackFirstView>) and that is sufficient to convince it that these are unrelated views, so it throws away the prior presented view instead of reusing it.

Resources