SwiftUI 4: navigationDestination()'s destination view isn't updated when state changes - ios

While experiments with the new NavigationStack in SwiftUI 4, I find that when state changes, the destination view returned by navigationDestination() doesn't get updated. See code below.
struct ContentView: View {
#State var data: [Int: String] = [
1: "One",
2: "Two",
3: "Three",
4: "Four"
]
var body: some View {
NavigationStack {
List {
ForEach(Array(data.keys).sorted(), id: \.self) { key in
NavigationLink("\(key)", value: key)
}
}
.navigationDestination(for: Int.self) { key in
if let value = data[key] {
VStack {
Text("This is \(value)").padding()
Button("Modify It") {
data[key] = "X"
}
}
}
}
}
}
}
Steps to reproduce the issue:
Run the code and click on the first item in the list. That would bring you to the detail view of that item.
The detail view shows the value of the item. It also has a button to modify the value. Click on that button. You'll observe that the value in the detail view doesn't change.
I debugged the issue by setting breakpoints at different place. My observations:
When I clicked the button, the code in List get executed. That's as expected.
But the closure passed to navigationDestination() doesn't get executed, which explains why the detail view doesn't get updated.
Does anyone know if this is a bug or expected behavior? If it's not a bug, how can I program to get the value in detail view updated?
BTW, if I go back to root view and click on the first item to go to its detail view again, the closure passed to navigationDestination() get executed and the detail view shows the modified value correctly.

#NoeOnJupiter's solution and #Asperi's comment are very helpful. But as you see in my comments above, there were a few details I wasn't sure about. Below is a summary of my final understanding, which hopefully clarifies the confusion.
navigationDestination() takes a closure parameter. That closure captures an immutable copy of self.
BTW, SwiftUI takes advantage of property wrapper to make it possible to "modify" an immutable value, but we won't discuss the details here.
Take my above code as an example, due to the use of #State wrapper, different versions of ContentView (that is, the self captured in the closure) share the same data value.
The key point here is I think the closure actually has access to the up-to-date data value.
When an user clicks on the "Modify it" button, the data state changes, which causes body re-evaluted. Since navigationDestination() is a function in body, it get called too. But a modifier function is just shortcut to modifier(SomeModifier()). The actual work of a Modifier is in its body. Just because a modifier function is called doesn't necessarilly means the corresponding Modifier's body gets called. The latter is a mystery (an implementation detail that Apple don't disclose and is hard to guess). See this post for example (the author is a high reputation user in Apple Developer Forum):
In my opinion, it definitely is a bug, but not sure if Apple will fix it soon.
One workaround, pass a Binding instead of a value of #State variables.
BTW, I have a hypothesis on this. Maybe this is based on a similar approach as how SwiftUI determines if it recalls a child view's body? My guess is that it might be a design, instead of a bug. For some reason (performance?) the SwiftUI team decided to cache the view returned by navigationDestination() until the NavigationStack is re-constructed. As a user I find this behavior is confusing, but it's not the only example of the inconsistent behaviors in SwiftUI.
So, unlike what I had thought, this is not an issue with closure, but one with how modifier works. Fortunately there is a well known and robust workaround, as suggested by #NoeOnJupiter and #Asperi.
Update: an alternative solution is to use EnvironmentObject to cause the placeholder view's body get re-called whenever data model changes. I ended up using this approach and it's reliable. The binding approach worked in my simple experiments but didn't work in my app (the placeholder view's body didn't get re-called when data model changed. I spent more than one day on this but unfortunately I can't find any way to debug it when binding stopped working mysteriously).

The button is correctly changing the value. By default navigationDestination does't create a Binding relation between the parent & child making the passed values immutable.
So you should create a separate struct for the child in order to achieve Bindable behavior:
struct ContentView: View {
#State var data: [Int: String] = [
1: "One",
2: "Two",
3: "Three",
4: "Four"
]
var body: some View {
NavigationStack {
List {
ForEach(Array(data.keys).sorted(), id: \.self) { key in
NavigationLink("\(key)", value: key)
}
}
.navigationDestination(for: Int.self) { key in
SubContentView(key: key, data: $data)
}
}
}
}
struct SubContentView: View {
let key: Int
#Binding var data: [Int: String]
var body: some View {
if let value = data[key] {
VStack {
Text("This is \(value)").padding()
Button("Modify It") {
data[key] = "X"
}
}
}
}
}

Related

Why is this SwiftUI state not updated when passed as a non-binding parameter?

Here's what I want to do:
Have a SwiftUI view which changes a local State variable
On a button tap, pass that variable to some other part of my application
However, for some reason, even though I update the state variable, it doesn't get updated when it's passed to the next view.
Here's some sample code which shows the problem:
struct NumberView: View {
#State var number: Int = 1
#State private var showNumber = false
var body: some View {
NavigationStack {
VStack(spacing: 40) {
// Text("\(number)")
Button {
number = 99
print(number)
} label: {
Text("Change Number")
}
Button {
showNumber = true
} label: {
Text("Show Number")
}
}
.fullScreenCover(isPresented: $showNumber) {
SomeView(number: number)
}
}
}
}
struct SomeView: View {
let number: Int
var body: some View {
Text("\(number)")
}
}
If you tap on "Change Number", it updates the local state to 99. But when I create another view and pass this as a parameter, it shows 1 instead of 99. What's going on?
Some things to note:
If you uncomment Text("\(number)"), it works. But this shouldn't be necessary IMO.
It also works if you make SomeView use a binding. But for my app, this won't work. My actual use case is a 'select game options' view. Then, I will create a non-SwiftUI game view and I want to pass in these options as parameters. So, I can't have bindings all the way down my gaming code just because of this bug. I want to just capture what the user enters and create a Parameters object with that data.
It also works if you make it a navigationDestination instead of a fullScreenCover. ¯\(ツ)/¯ no idea on that one...
A View is a struct, therefore its properties are immutable, so the view can not change its own properties. This is why changing the property named number from inside the body of the view needs this property to be annotated with a #State property wrapper. Thanks to Swift and SwiftUI, transparent read and write callbacks let the value being seen changed. So you must not pass number as a parameter of SomeView() when calling fullScreenCover(), but pass a reference to number, for the callbacks to be systematically called: $number. Since you are not passing an integer anymore to construct struct SomeView, the type of the property named number in this struct can not any longer be an integer, but must be a reference to an integer (namely a binding): use the #Binding annotation for this.
So, replace SomeView(number: number) by SomeView(number: $number) and let number: Int by #Binding var number: Int to do the job.
Here is the correct source code:
import SwiftUI
struct NumberView: View {
#State var number: Int = 1
#State private var showNumber = false
var body: some View {
NavigationStack {
VStack(spacing: 40) {
// Text("\(number)")
Button {
number = 99
print(number)
} label: {
Text("Change Number")
}
Button {
showNumber = true
} label: {
Text("Show Number")
}
}
.fullScreenCover(isPresented: $showNumber) {
SomeView(number: $number)
}
}
}
}
struct SomeView: View {
#Binding var number: Int
var body: some View {
Text("\(number)")
}
}
After all that said to obtain a valid source code, their is a little trick that has not been explained up to now: if you simply replace in your source code Text("Change Number") by Text("Change Number \(number)"), without using $ reference nor #Binding keywords anywhere, you will see that the problem is also automatically solved! No need to use #binding in SomeView! This is because SwiftUI makes optimizations when building a tree of views. If it knows that the displayed view changed (not only its properties), it will compute the view with updated #State values. Adding number to the button label makes SwiftUI track changes of the number state property and it now updates its cached value to display the Text button label, therefore this new value will be correctly used to create SomeView. All of that may be considered as strange things, but is simply due to optimizations in SwiftUI. Apple does not fully explain how it implements optimizations building a tree of views, there are some informations given during WWDC events but the source code is not open. Therefore, you need to strictly follow the design pattern based on #State and #Binding to be sure that the whole thing works like it should.
All of that said again, one could argue that Apple says that you do not have to use #Binding to pass a value to a child view if this child view only wants to access the value: share the state with any child views that also need access, either directly for read-only access, or as a binding for read-write access (https://developer.apple.com/documentation/swiftui/state). This is right, but Apple says in the same article that you need to place [state] in the highest view in the view hierarchy that needs access to the value. With Apple, needing to access a value means that you need it to display the view, not only to do other computations that have no impact on the screen. This is this interpretation that lets Apple optimize the computation of the state property when it needs to update NumberView, for instance when computing the content of the Text("Change Number \(number)") line. You could find it really tricky. But there is a way to understand that: take the initial code you wrote, remove the #State in front of var number: Int = 1. To compile it, you need to move this line from inside the struct to outside, for instance at the very first line of your source file, just after the import declaration. And you will see that it works! This is because you do not need this value to display NumberView. And thus, it is perfectly legal to put the value higher, to build the view named SomeView. Be careful, here you do not want to update SomeView, so there is no border effects. But it would not work if you had to update SomeView.
Here is the code for this last trick:
import SwiftUI
// number is declared outside the views!
var number: Int = 1
struct NumberView: View {
// no more state variable named number!
// No more modification: the following code is exactly yours!
#State private var showNumber = false
var body: some View {
NavigationStack {
VStack(spacing: 40) {
// Text("\(number)")
Button {
number = 99
print(number)
} label: {
Text("Change Number")
}
Button {
showNumber = true
} label: {
Text("Show Number")
}
}
.fullScreenCover(isPresented: $showNumber) {
SomeView(number: number)
}
}
}
}
struct SomeView: View {
let number: Int
var body: some View {
Text("\(number)")
}
}
This is why you should definitely follow the #State and #Binding design pattern, taking into account that if you declare a state in a view that does not use it to display its content, you should declare this state as a #Binding in child views even if those children do not need to make changes to this state. The best way to use #State is to declare it in the highest view that needs it to display something: never forget that #State must be declared in the view that owns this variable; creating a view that owns a variable but that does not have to use it to display its content is an anti-pattern.
Since number isn't read in body, SwiftUI's dependency tracking detect it. You can give it a nudge like this:
.fullScreenCover(isPresented: $showNumber) { [number] in
Now a new closure will be created with the updated number value whenever number changes. Fyi the [number] in syntax is called a "capture list", read about it here.
Nathan Tannar gave me this explanation via another channel which I think gets to the crux of my problem. It does seem that this is a SwiftUI weirdness caused by knowing when and how it updates views based on state. Thanks Nathan!
It’s because the number isn’t “read” in the body of the view. SwiftUI is smart in that it only triggers view updates when a dependency of the view changes. Why this causes issues with the fullScreenCover modifier is because it captures an #escaping closure for the body. Which means it’s not read until the cover is presented. Since its not read the view body will not be re-evaluated when the #State changes, you can validate this by setting a breakpoint in the view body. Because the view body is not re-evaluated, the #escaping closure is never re-captured and thus it will hold a copy of the original value.
As a side note, you’ll find that once you present the cover for the first time and then dismiss, subsequent presentations will update correctly.
Arguably this seems like a SwiftUI bug, the fullScreenCover probably shouldn’t be #escaping. You can workaround by reading the number within the body, or wrapping the modifier with something like this, since here destination is not #escaping captured so the number will be read in the views body evaluation.
struct FullScreenModifier<Destination: View>: ViewModifier {
#Binding var isPresented: Bool
#ViewBuilder var destination: Destination
func body(content: Content) -> some View {
content
.fullScreenCover(isPresented: $isPresented) {
destination
}
}
}

Is there no way to detect when a SwiftUI view is dismissed?

I have an app that is built using a NavigationSplitView with a menu on the left and a map on the right. The left view controls the state of the map depending on what view is currently shown in the menu. Previously I saved my own routing state model for the navigation when NavigationLinks where activated using tags and selection. This made it possible to know the exact state of the apps routing at all times. With the new NavigationStack, we have to use NavigationPath which can not be monitored since the internal values are private.
Another option we had previously for knowing when a view was dismissed was to create a StateObject for the view when the view was created, then it will be deallocated as the view is dismissed. However that won't work in NavigationStack since the new .navigationDestination is called multiple times like any type of view rendering, making the StateObject allocate and deallocate just as many times.
And yes, I know about .onAppear and .onDisappear. However, these events are irrelevant in this situation since they can be called multiple times during the views lifecycle e.g. when another view is presented on top of the current view etc.
Is it possible to detect when a view truly disappears (is dismissed) in SwiftUI?
This isn't an answer to how to detect when a screen disappears, but rather a solution to the first part of your problem.
With a NavigationStack, you don't have to use a NavigationPath object as the path.
The initialiser is:
init(path: Binding<Data>, #ViewBuilder root: () -> Root) where Data : MutableCollection, Data : RandomAccessCollection, Data : RangeReplaceableCollection, Data.Element : Hashable
so path can be a Binding of any array who's elements are Hashable. e.g.
struct ContentView: View {
enum Routing: Hashable {
case screen1, screen2(String)
}
#State private var path: [Routing] = []
var body: some View {
NavigationStack(path: $path) {
List {
NavigationLink("Show screen 1", value: Routing.screen1)
NavigationLink("Show screen 2", value: Routing.screen2("Fred"))
}
.navigationDestination(for: Routing.self) { screen in
switch screen {
case .screen1:
Text("This is screen 1")
case let .screen2(name):
Text("This is screen 2 - name: \(name)")
}
}
}
.onChange(of: path) { newValue in
path.forEach { screen in
print(screen)
}
}
}
}
As your path is not an opaque object you can use that to determine your app's current state.

SwiftUI MVVM approach with vars in View

I'm building an app in SwiftUI, based on the MVVM design pattern. What I'm doing is this:
struct AddInvestment: View {
#ObservedObject private var model = AddInvestmentVM()
#State private var action: AssetAction?
#State private var description: String = ""
#State private var amount: String = ""
#State private var costPerShare: String = ""
#State private var searchText: String = ""
#State var asset: Asset?
var body: some View {
NavigationView {
VStack {
Form {
Section("Asset") {
NavigationLink(model.chosenAsset?.completeName ?? "Scegli asset") {
AssetsSearchView()
}
}
Section {
Picker(action?.name ?? "", selection: $action) {
ForEach(model.assetsActions) { action in
Text(action.name).tag(action as? AssetAction)
}
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
}
Section {
TextField("", text: $amount, prompt: Text("Unità"))
.keyboardType(UIKit.UIKeyboardType.decimalPad)
TextField("", text: $costPerShare, prompt: Text("Prezzo per unità"))
.keyboardType(UIKit.UIKeyboardType.decimalPad)
}
}
}
.navigationTitle("Aggiungi Investimento")
}
.environmentObject(model)
.onAppear {
model.fetchBaseData()
}
}
}
Then I have my ViewModel, this:
class AddInvestmentVM: ObservableObject {
private let airtableApiKey = "..."
private let airtableBaseID = "..."
private let assetsTableName = "..."
private let assetsActionsTableName = "..."
private let airtable = Airtable.init("...")
private var tasks = [AnyCancellable]()
#Published var chosenAsset: Asset?
#Published var assets = [Asset]()
#Published var assetsActions = [AssetAction]()
init() { }
func fetchBaseData() {
print("Fetching data...")
let assetActionsRequest = AirtableRequest.init(baseID: airtableBaseID, tableName: assetsActionsTableName, view: nil)
let assetsActionsPublisher: AnyPublisher<[AssetAction], AirtableError> = airtable.fetchRecords(request: assetActionsRequest)
assetsActionsPublisher
.eraseToAnyPublisher()
.sink { completion in
print("** **")
print(completion)
} receiveValue: { assetsActions in
print("** **")
print(assetsActions)
self.assetsActions = assetsActions
}
.store(in: &tasks)
}
}
Now, as you can see I have some properties on the view that are binded to some text fields. Let's take these in consideration:
#State private var description: String = ""
#State private var amount: String = ""
#State private var costPerShare: String = ""
#State private var searchText: String = ""
Keeping in mind the MVVM pattern, should these properties be declared in the ViewModel and binded from there? Or is this a right approach?
Instead of giving you a concrete answer which concrete variable you might move to the view model, I would like to give you a more general answer, which might also help you in the decision of other use cases, and eventually should help to answer your question yourself ;)
A View Model publishes the binding (I am not talking about a #Binding here!), which completely and unambiguously describes what a view shall render. The how it actually looks like may be still part of the view.
Tip: define a struct which contains all the variables constituting the binding, then publish this struct in your view model. You may name this binding ViewState.
If we take this strict, it means in other words, that for each possible value of the binding, there is one and only one visual representation of the view.
However, in practice, it is favourable to relax this a bit, otherwise you would have to specify and handle even the scrolling position of a scroll view, too. How far you go with this may depend on the complexity of your view and the whole scenario. But the rule, that the binding is the definitive source of truth for the view and determines what it is rendering should not be violated.
Generally, in a scenario where your model is not mutable (i.e. there are no user actions which alter the model), you very rarely would use #State variables in a SwiftUI view, since there is nothing which is variable.
Even when it happens that the elements in the list change or the order or the number of elements change, the list view always receives a constant array of elements whose elements are also not mutable by the user. Thus, you use a let elements: [Element] in your SwiftUI view.
On the other hand, your view model may use a model which publishes a value of [Element] and the view model observes it, and then mutates the binding accordingly.
Another principle of MVVM is (actually any MV* pattern) that your view does not perform logic on its own and it never ever changes the binding.
Instead, your view signals the user's "intent" via call backs (aka actions, events, intents) which eventually will reach the view model and then handled there, which in turn may eventually affect the binding.
Strictly, that would mean, if you have a scenario where a user can edit a string, and IFF you use a binding for this string, the string cannot be mutated by editing of the user. Instead, each editing command will be send to the view model, the view model then handles the changes, and sends back the mutated binding which eventually will reflect the user's changes.
Also, in practice there might be more favourable solutions: a view might use its own "edit buffer" for the string, using a #State variable. This string gets mutated "internally" within the view when the user edits the text. The model only gets informed when the user "commits" the changes, or when the user taps the "back" or "done" button. This approach is favourable when the "editing" itself is simple and no complex validation is required during editing, or when there is no need to trigger side effects (like updating a suggestion list in a search bar). Otherwise, you would route the editing through the view model.
Again in practice, you decide how far you go with a view that has "it's own behaviour". It depends on the use case and what is favourable in terms of avoiding unnecessary complex solutions which don't make the solution any better through being too strict to the pattern.
Conclusion:
In MVVM, you use #State variables in Views only iff your View adds a behaviour which does not strictly need to be monitored by the View Model and does not violate the rule that the Binding is the definitive source of truth from the perspective of the view and that the View Model still is the authority for the performed logic.
I tend to think of the View as being the definition of the human interface of an app for a device or set of devices (iPhone, iPad, Mac, etc). I tend to think of the Model as the place where the applications logic goes that is not part of any View (business logic, data manipulation, network manipulation, etc). I think of the ViewModel as being the place where any logic exits that the View needs to make the View work, such as formatting data for display on the View. I think you are looking for where the single source of truth lives for a data element needed by the View. To my thinking it should only live in the ViewModel if and only if it is specific to the View. Any logic or data that is about the application should live in the Model, not the ViewModel. By doing this I make my Model more reusable, more independent of the View. There are always borderline cases and I think about if this data or logic would need to be recreated if I built an entirely separate second View, say one that worked with a Browser via HTML/JavaScript/Etc. If I would have to recreate that data or logic with a new View layer, then I think the data or logic belongs in the Model. If the data or logic is specific to this View layer, then I would put it in the ViewModel for that View.
Using the above thinking your AddInvestment class looks like something I would put in the Model, not the ViewModel. Of course, it is all a matter of taste and I do not think there is any one right answer here, but that is how I think I would slice and dice it in my own apps.

SwiftUI #State variables not getting deinitialized

I have a SwiftUI View where I declare a condition like this
#State var condition = UserDefaults.standard.bool(forKey: "JustKey")
When I first push this view into the navigation stack condition variable is getting the correct value. Then when I pop this View I change the value in UserDefaults but when I push this screen again condition variable remembers the old value which it got first time.
How to find a workaround for this because I want to reinitialize my condition variable each time I enter my custom view where I declared it?
In this case, #State is behaving exactly like it is supposed to: as a persistent store for the component it's attached to.
Fundamentally, pushing a view with a NavigationLink is like any other component in the view hierarchy. Whether or not the screen is actually visible is an implementation detail. While SwiftUI is not actually rendering your hidden UI elements after closing a screen, it does hold on to the View tree.
You can force a view to be completely thrown away with the .id(_:) modifier, for example:
struct ContentView: View {
#State var i = 0
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView().id(i)) {
Text("Show Detail View")
}
Button("Toggle") {
self.i += 1
UserDefaults.standard.set(
!UserDefaults.standard.bool(forKey: "JustKey"),
forKey: "JustKey")
}
}
}
}
}
The toggle button both modifies the value for JustKey and increments the value that we pass to .id(i). When the singular argument to id(_:) changes, the id modifier tells SwiftUI that this is a different view, so when SwiftUI runs it's diffing algorithm it throws away the old one and creates a new one (with new #State variables). Read more about id here.
The explanation above provides a workaround, but it's not a good solution, IMO. A much better solution is to use an ObservableObject. It looks like this:
#ObservedObject condition = KeyPathObserver(\.JustKey, on: UserDefaults.standard)
You can find the code that implements the KeyPathObserver and a full working Playground example at this SO answer

SwiftUI: NavigationLink pops immediately if used within ForEach

I'm using a NavigationLink inside of a ForEach in a List to build a basic list of buttons each leading to a separate detail screen.
When I tap on any of the list cells, it transitions to the detail view of that cell but then immediately pops back to the main menu screen.
Not using the ForEach helps to avoid this behavior, but not desired.
Here is the relevant code:
struct MainMenuView: View {
...
private let menuItems: [MainMenuItem] = [
MainMenuItem(type: .type1),
MainMenuItem(type: .type2),
MainMenuItem(type: .typeN),
]
var body: some View {
List {
ForEach(menuItems) { item in
NavigationLink(destination: self.destination(item.destination)) {
MainMenuCell(menuItem: item)
}
}
}
}
// Constructs destination views for the navigation link
private func destination(_ destination: ScreenDestination) -> AnyView {
switch destination {
case .type1:
return factory.makeType1Screen()
case .type2:
return factory.makeType2Screen()
case .typeN:
return factory.makeTypeNScreen()
}
}
If you have a #State, #Binding or #ObservedObject in MainMenuView, the body itself is regenerated (menuItems get computed again) which causes the NavigationLink to invalidate (actually the id change does that). So you must not modify the menuItems arrays id-s from the detail view.
If they are generated every time consider setting a constant id or store in a non modifying part, like in a viewmodel.
Maybe I found the reason of this bug...
if you use iOS 15 (not found iOS 14),
and you write the code NavigationLink to go to same View in different locations in your projects, then this bug appear.
So I simply made another View that has different destination View name but the same contents... then it works..
you can try....
sorry for my poor English...

Resources