I have transitioned my swiftui app to the new NavigationStack programatically managed using NavigationStack(path: $visibilityStack). While doing so, I have found an unexpected behaviour of @State that makes me think the view is not dismissed correctly.
In fact, when I am replacing the view with another one in the stack, the @State variable is keeping its current value instead of being initialised, as it should be when presenting a new view.
Is it a bug? Is it a misconception (mine or someone else :-))? Your thoughts are welcome. As it is, the only workaround I see is to maintain a state in another object and synchronise...
I have created a mini project. To reproduce, click on the NavigationLink, then click on the 'show other fruits' button to change the @State in the current View, then click a fruit button to change the view. The new view appears with the previous state (showMoreText is true, although it is declared as false during init). While doing more test, it also appears that .onAppear is not called either. When using the old style NavigationView and isPresented, views were correctly initialised.
Full code here (except App which is the basic one), which should have been a good tutorial.
EDIT per Yrb answer: The data is handled in the fruitList of Model Fruit to keep our ViewController clean.
The controller FruitViewController is responsible to call the new views:
class Fruit : Hashable, Identifiable {
// to conform to Identifiable
var id: String
init(name: String) {
self.id = name
}
// to conform to Hashable which inherit from Equatable
static func == (lhs: Fruit, rhs: Fruit) -> Bool {
return (lhs.id == rhs.id)
}
// to conform to Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
let fruitList = [Fruit(name: "Banana"), Fruit(name: "Strawberry"), Fruit(name: "Pineapple")]
class FruitViewController: ObservableObject {
@Published var visibilityStack : [Fruit] = []
// the functions that programatically call a 'new' view
func openView(fruit: Fruit) {
visibilityStack.removeAll()
visibilityStack.append(fruit)
// visibilityStack[0] = fruit // has the same effect
}
// another one giving an example of what could be a deep link
func bananaAndStrawberry() {
visibilityStack.append(fruitList[0])
visibilityStack.append(fruitList[1])
}
}
The main ContentView which provides the NavigatoinStack:
struct ContentView: View {
@StateObject private var fruitViewController = FruitViewController()
var body: some View {
NavigationStack(path: $fruitViewController.visibilityStack) {
VStack {
Button("Pile Banana and Strawberry", action: bananaAndStrawberry)
.padding(40)
List(fruitList) {
fruit in NavigationLink(value: fruit) {
Text(fruit.id)
}
}
}
.navigationDestination(for: Fruit.self) {
fruit in FruitView(fruitViewController: fruitViewController, fruit: fruit)
}
}
}
func bananaAndStrawberry() {
fruitViewController.bananaAndStrawberry()
}
}
The subview FruitView where the @State variable should be initialised:
struct FruitView: View {
// the state that will change and not be initialised
@State private var showMoreText = false
@ObservedObject var fruitViewController: FruitViewController
var fruit: Fruit
var body: some View {
Text("Selected fruit: " fruit.id)
if (showMoreText) {
Text("The text should disappear when moving to another fruit")
HStack(spacing: 10) {
ForEach(fruitList) {
aFruit in Button(aFruit.id) {
fruitViewController.openView(fruit: aFruit)
}
}
}
} else {
Button("Show other fruits", action: showButtons)
}
}
// let's change the state
func showButtons() {
showMoreText = true
}
}
ADDENDUM after Yrb answer:
I have done another exercise, to maybe better explain my point. Add 3 views to the stack array with visibilityStack.append . Let's call the initial state: state 0. It will create a stack like this:
_View 1 - state 0_
_View 2 - state 0_
_View 3 - State 0_ (the one shown on the screen)
Let's now modify the state of our View 3 to obtain:
_View 1 - state 0_
_View 2 - state 0_
_View 3 - State 1_ (the one shown on the screen)
Can you tell me what is happening when you remove View 2 using visibilityStack.remove(at: 1)?
The answer is:, you will obtain the following stack:
_View 1 - state 0_
_View 3 - State 0_ (the one shown on the screen)
So the View 2 is not destroyed. The last View in the stack is the one that is destroyed.
To Yrb point, it seems like if NavigationStack was a mixed approach between the ability to deal with the views, but with a kind of Model in mind.
CodePudding user response:
Welcome to Stack Overflow! This is working exactly as expected. When you are in FruitView
and you select another fruit, your are still in the same FruitView
. All you have done is change the contents of the variable in the view model. When you change the contents, this triggers the @Published var visibilityStack
to tell the view to update.
A few things about class FruitViewController
. First of all, it is NOT a View Controller. A View Controller is a specialized class in UIKit that controls how a view is shown. This is SwiftUI, and when you create a class to hold your data, and notify the view when the data has changed, we call that a View Model. To avoid some later confusion I have cleaned up your View Model and added comments:
// Normally, you would name this for the view that creates it, but
// ContentView is pretty generic
class FruitViewModel: ObservableObject {
@Published var visibilityStack : [Fruit] = []
// fruitList is never changed so it can be a let. If it is going
// to be changed, and since it should then affect the redrawing of
// the views, you would make it @Published var fruitList, but only
// if by changing it you want the views redrawn(in this case, yes,
// because it is the list of choices for the user.
let fruitList = [Fruit(name: "Banana"), Fruit(name: "Strawberry"), Fruit(name: "Pineapple")]
// the functions that programatically call a 'new' view
// These functions do not call a new view. They simply update the
// data held in the view model.
func openView(fruit: Fruit) {
visibilityStack.removeAll()
visibilityStack.append(fruit)
// visibilityStack[0] = fruit // has the same effect
}
// another one giving an example of what could be a deep link
// This is not a link to a view. It is simply another way of
// updating the data held in the View Model.
func bananaAndStrawberry() {
visibilityStack.append(fruitList[0])
visibilityStack.append(fruitList[1])
}
}
In the end, I would review Apple's SwiftUI Tutorials and Demystify SwiftUI.
CodePudding user response:
I would say that Adkarma has a point. The view is not "deiniting" as one might expect because they've "changed the path".
The thing is it's identity is being preserved because (simplification/educated guess warning) it is NavigationStack[0] as? FruitView
and NavigationStack[0] as FruitView
is still there. The NavigationStack
will not fully clean it up until the RootView has reappeared. NavigationStack
doesn't clean up any views until the new one has appeared.
So, if you change FruitViewController
to:
func openView(fruit: Fruit) {
visibilityStack.append(fruit)
}
The NavigationStack will work as expected and you will continue to add views to the stack - I've added a counter
and the visibilityStack
to the FruitView to make what's happening clearer as well as some messages to make it clearer when things get made and cleaned up.
struct ContentView: View {
@StateObject private var fruitController = FruitViewController()
var body: some View {
NavigationStack(path: $fruitController.visibilityStack) {
VStack {
...
}
.onAppear() {
print("RootView Appeared")
}
.navigationDestination(for: Fruit.self) {
fruit in FruitView(fruit: fruit).environmentObject(fruitController)
}
}
}
struct FruitView: View {
// the state that will change and not be initialised
@State private var showMoreText = false
@State private var counter = 0
@EnvironmentObject var fruitViewController: FruitViewController
var fruit: Fruit
var body: some View {
VStack {
Text("Selected fruit: " fruit.id)
Text("How many updates: \(counter)")
if (showMoreText) {
Text("The text should disappear when moving to another fruit")
HStack(spacing: 10) {
Text("Who is in the fruitList")
ForEach(fruitViewController.fruitList) {
aFruit in Button(aFruit.id) {
counter = 1
fruitViewController.openView(fruit: aFruit)
}
}
}
HStack(spacing:10) {
Text("Who is in the visibility stack")
ForEach(fruitViewController.visibilityStack) {
aFruit in Button(aFruit.id) {
counter = 1
fruitViewController.openView(fruit: aFruit)
}
}
}
} else {
Button("Show other fruits", action: showButtons)
}
}.onDisappear() {
print("Fruit view \(fruit.id) is gone")
}.onAppear() {
print("Hi I'm \(fruit.id), I'm new here.")
//dump(env)
}
}
// let's change the state
func showButtons() {
showMoreText = true
}
}
But this doesn't seem to be the behavior Adkarma wants. They don't want to go deeper and deeper. They want a hard swap at the identical position? Correct? The NavigationStack
seems to try to increase efficiency by not destroying an existing view of the same type, which of course leaves the @State objects intact.
The navigation path is being driven by a binding to an Array
inside an ObservableObject
. The NavigationStack
continues to believe that it is at fruitViewController.visibilityStack[0]
... because it is. It doesn't seem to care about the content inside the wrapper beyond its Type.
The preserved FruitView will re-run the body code, but since it isn't a "new view" (It's still good old NavigationStack[0] as FruitView)it will not hard refresh the @State vars.
But "I zero'd that out!" you say, but NavigationStack
seems to have copy of it that DIDN'T, and it won't until it can make a new view appear, which it doesn't need to because there is in fact still something at NavigationStack[0] right away again.
I think Adkarma is spot on that this is related to deep linking and here are some articles I quite liked about that:
- https://www.pointfree.co/blog/posts/78-reverse-engineering-swiftui-s-navigationpath-codability
- https://swiftwithmajid.com/2022/06/21/mastering-navigationstack-in-swiftui-deep-linking/
Also interesting:
For what it's worth, to "get it done" I would just reset the variables in View when there is a content change in the Nav array explicitly checking with an onReceive
, but I agree that seems a bit messy. I also would be interested if anyone has a more elegant solution to a hard swap of a navigation path that ends up at the same type of view at the same "distance" from the root like Adkarma's example.
struct FruitView: View {
// the state that will change and not be initialised
@State private var showMoreText = false
@State private var counter = 0
@EnvironmentObject var fruitViewController: FruitViewController
var fruit: Fruit
var body: some View {
VStack {
Text("Selected fruit: " fruit.id)
Text("How many updates: \(counter)")
if (showMoreText) {
Text("The text should disappear when moving to another fruit")
HStack(spacing: 10) {
Text("Who is in the fruitList")
ForEach(fruitViewController.fruitList) {
aFruit in Button(aFruit.id) {
counter = 1
fruitViewController.openView(fruit: aFruit)
}
}
}
} else {
Button("Show other fruits", action: showButtons)
}
}.onReceive(fruitViewController.$visibilityStack) { value in
counter = 0
showMoreText = false
}
}
// let's change the state
func showButtons() {
showMoreText = true
}
}
FWIW a NavigationPath
it will also be looking at position rather than content so you will still need to look at it with .onRecieve
//Still cares about length/position not content.
class FruitNav: ObservableObject {
@Published var path = NavigationPath()
@Published var fruitList = [Fruit(name: "Banana"), Fruit(name: "Strawberry"), Fruit(name: "Pineapple")]
// the functions that programatically call a 'new' view
func openView(fruit: Fruit) {
path.removeLast()
path.append(fruit)
}
func replacePath(fruit: Fruit) {
path = NavigationPath()
path.append(fruit)
}
// another one giving an example of what could be a deep link
func bananaAndStrawberry() {
path.append(fruitList[0])
path.append(fruitList[1])
}
}