NavigationStack is breaking binding - swiftui

I recently tried to migrate from SwiftUI NavigationView to NavigationStack, but experienced some problems with bindings. If the view stack has changes, my bindings seem to convert into some sort of local state instead of bindings.
I was able to recreate the issue in a much simplified version of my code. In this simplified version I have #State counter in CounterView which creates a new counter each time the view is loaded. The counter is passed as a binding to EditCoutnerView. This binding is broken when if the NavigationStack changes.
To recreate this I have to navigate as follows:
Counters -> CounterView -> EditCounterView
Go back to Counters
Counters -> OtherCounter
Go back to Counters
Counters -> CounterView -> EditCounterView (Binding broken, state not updated)
Go back to CounterView (State not changed)
CounterView -> EditCounterView (State updated)
I can't figure out why the binding is broken. Any ideas what to do?
import SwiftUI
#main
struct PlaygroundAppApp: App {
#StateObject var router: Router = .init()
var body: some Scene {
WindowGroup {
NavigationStack(path: self.$router.path) {
Counters()
}
.environmentObject(self.router)
}
}
}
struct Other: Hashable {}
struct New: Hashable {}
final class Router: ObservableObject {
#Published var path = NavigationPath()
}
struct Counters: View {
#EnvironmentObject var router: Router
var body: some View {
VStack {
NavigationLink("New Counter", value: New())
NavigationLink("Other Counters", value: Other())
.padding()
}
.navigationTitle("Main")
.navigationDestination(for: New.self) { examination in
CounterView()
}
.navigationDestination(for: Other.self) { examination in
OtherCounter()
}
}
}
struct OtherCounter: View {
var body: some View {
VStack {
NavigationLink("Counter", value: New())
}
.navigationTitle("Other Counter")
}
}
struct CounterView: View {
#State private var counter: Int = 0
var body: some View {
VStack {
NavigationLink("Edit counter", value: counter)
.padding()
Text("COUNT \(self.counter)")
}
.navigationDestination(for: Int.self, destination: { value in
EditCounterView(count: self.$counter)
})
.navigationTitle("Counter")
}
}
struct EditCounterView: View {
#Binding var count: Int
var body: some View {
VStack {
Text("Current count \(count)")
Button("increase") {
self.count += 1
}
.padding()
Button("decrease") {
self.count -= 1
}
}
.navigationTitle("Edit Counter")
}
}

If you go to New Counter->Edit Counter->Increase and it goes to 1, then go back, back and again go to New Counter you'll see its back at zero. The state is lost when you exit back out of the New Counter screen (CounterView). If you want to maintain state across screens you need to move it up to a common parent. It's probably not a good idea to use a navigation value as a counter usually it's an ID that doesn't change. If you say what you are trying to achieve perhaps we could come up with a better data model.

As lorem ipsum pointed out, NavigationLink with destination and label fixes this issue.

Related

SwiftUI - TabView/NavigationLink navigation breaks when using a custom binding

I'm having trouble with what I think may be a bug, but most likely me doing something wrong.
I have a slightly complex navigation state variable in my model that I'm using for tracking/setting state between tab and sidebar presentations when multitasking on iPad. That all works fine except in tab mode, once I use a navigation link once I can't seem to use one again, whether the binding is on my tab view or navigation links in a list.
Would really appreciate any thoughts on this,
Cheers!
Example
NavigationItem.swift
enum SubNavigationItem: Hashable {
case overview, user, hobby
}
enum NavigationItem: Hashable {
case home(SubNavigationItem)
case settings
}
Model.swift
final class Model: ObservableObject {
#Published var selectedTab: NavigationItem = .home(.overview)
}
SwiftUIApp.swift
#main
struct SwiftUIApp: App {
#StateObject var model = Model()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
ContentView.swift
struct ContentView: View {
var body: some View {
AppTabNavigation()
}
}
AppTabNavigation.swift
struct AppTabNavigation: View {
#EnvironmentObject private var model: Model
var body: some View {
TabView(selection: $model.selectedTab) {
NavigationView {
HomeView()
}
.tabItem {
Label("Home", systemImage: "house")
}
.tag(NavigationItem.home(.overview))
NavigationView {
Text("Settings View")
}
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(NavigationItem.settings)
}
}
}
HomeView.swift
I created a binding here because selection required an optional <NavigationItem?> not
struct HomeView: View {
#EnvironmentObject private var model: Model
var body: some View {
let binding = Binding<NavigationItem?>(
get: {
model.selectedTab
},
set: {
guard let item = $0 else { return }
model.selectedTab = item
}
)
List {
NavigationLink(
destination: Text("Users"),
tag: .home(.user),
selection: binding
) {
Text("Users")
}
NavigationLink(
destination: Text("Hobbies"),
tag: .home(.hobby),
selection: binding
) {
Text("Hobbies")
}
}
.navigationTitle("Home")
}
}
Second Attempt
I tried making the selectedTab property optional as #Lorem Ipsum suggested. Which means I can remove the binding there. But then the TabView doesn't work with the property. So I create a binding for that and have the same issue but with the tab bar!
Make the selected tab optional
#Published var selectedTab: NavigationItem? = .home(.overview)
And get rid of that makeshift binding variable. Just use the variable
$model.selectedTab
If the variable can never be nil then something is always selected IAW with that makeshift variable it will just keep the last value.

SwiftUI state discarded when scrolling items in a list

I'm very new to Swift and SwiftUI so apologies for the very basic question. I must be misunderstanding something about the SwiftUI lifecycle and it's interaction with #State.
I've have a list, and when you click on the row, it increments a counter. If I click on some row items to increment the counter, scroll down, and scroll back up again - the state is reset to 0 again. Can anyone point out where I'm going wrong? Many thanks.
struct TestView : View {
#State private var listItems:[String] = (0..<50).map { String($0) }
var body: some View {
List(listItems, id: \.self) { listItem in
TestViewRow(item: listItem)
}
}
}
struct TestViewRow: View {
var item: String
#State private var count = 0
var body: some View {
HStack {
Button(item, action: {
self.count += 1
})
Text(String(self.count))
Spacer()
}
}
}
Items in a List are potentially lazily-loaded, depending on the os (macOS vs iOS), length of the list, number of items on the screen, etc.
If a list item is loaded and then its state is changed, that state is not reassigned to the item if that item has been since unloaded/reloaded into the List.
Instead of storing #State on each List row, you could move the state to the parent view, which wouldn't be unloaded:
struct ContentView : View {
#State private var listItems:[(item:String,count:Int)] = (0..<50).map { (item:String($0),count:0) }
var body: some View {
List(Array(listItems.enumerated()), id: \.0) { (index,item) in
TestViewRow(item: item.item, count: $listItems[index].count)
}
}
}
struct TestViewRow: View {
var item: String
#Binding var count : Int
var body: some View {
HStack {
Button(item, action: {
count += 1
})
Text(String(count))
Spacer()
}
}
}

SwiftUI TabView index view not respecting page view index

My goal is to keep the source of truth for the current TabView index within app state. But I'm not seeing the behaviour I expect from the SwiftUI TabView index indicator.
What I can do is have one of either two outcomes: Have the binding update the source of truth properly, OR have the indicator work as expected. I can't seem to have both.
I've stripped down my code to a test case below. I expect the TabView index view (the indicator) to update and display the current view's index position in the array of the TabView's content (relatively of course, so + 1).
What happens is that the index doesn't update at all. In the original code it actually lags by one..
Can anyone with more experience with this setup offer any suggestions or point out where I'm going wrong?
import SwiftUI
class Item: Identifiable {
let id = Int.random(in: 0..<1000)
}
class SourceOfTruth: ObservableObject {
#Published var items: [Item] = [Item(), Item(), Item()]
/// Source of truth for app state
var _selectedItem: Int? {
willSet {
print("\(newValue ?? 0)")
}
}
/// Exposed SwiftUI binding
public lazy var selectedItem: Binding<Int> = Binding<Int>(get: {
self._selectedItem ?? -1
}, set: {
self._selectedItem = $0
})
init () {
_selectedItem = items.first?.id
}
}
struct BindingTestView: View {
#EnvironmentObject var appState: SourceOfTruth
var body: some View {
if appState.items.count > 0 {
TabView(selection: appState.selectedItem) {
ForEach(appState.items) { item in
Text("\(item.id)")
.tag(item.id)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
} else {
Text("No items")
}
}
}
#main
struct SwiftUICombineTestingApp: App {
let appState = SourceOfTruth()
var body: some Scene {
WindowGroup {
BindingTestView().environmentObject(appState)
}
}
}
You need to add #Published so the variable can be observed by the view:
#Published var _selectedItem: Int? {
willSet {
print("\(newValue ?? 0)")
}
}

PresentationMode.dismiss weird behaviour when using multiple NavigationLinks inside ForEach

My app has 4 views (let's call them View_A[root] -> View_B -> View_C -> View_D). The navigation between them was made using NavigationView/NavigationLink.
When I call self.presentationMode.wrappedValue.dismiss() from the last view(View_D) I expect it to dismiss the current view (D) only, but for some reason it dismissed ALL the views and stops at view A (root view).
That's weird.
I spent a couple of hours trying to figure out what's going on there and I found that
- if I remove "ForEach" from "View_A" it works correctly and only the last view is dismissed. Even though ForEach gets just 1 static object in this example.
The second weird thing is that
- if I don't change "self.thisSession.stats" to false it also works correctly dismissing only the last view.
This is super weird as View_A (as far as I understand) is not dependent on thisSession environment variable.
Any ideas on how to prevent View_C and View_B from being dismissed in this case? I wanna end up at View_C after clicking the link, not at View_A.
Any help is appreciated, it took me a while to find out where it comes from but I'm not smart enough to proceed any further ;)
import SwiftUI
struct A_View: View {
#EnvironmentObject var thisSession: CurrentSession
var body: some View {
NavigationView {
VStack {
Text("View A")
ForEach([TestObject()], id: \.id) { _ in
NavigationLink(destination: View_B() ) {
Text("Move to View B")
}
}
}
}
}
}
struct View_B: View {
var body: some View {
NavigationView {
NavigationLink(destination: View_C()
) {
Text("GO TO VIEW C")
}
}
}
}
struct View_C: View {
var body: some View {
ZStack {
NavigationView {
NavigationLink(destination: View_D()) {
Text("GO TO VIEW D")
}
}
}
}
}
struct View_D: View {
#EnvironmentObject var thisSession: CurrentSession
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
ZStack {
VStack {
Button(action: {
self.thisSession.stats = false
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Return!")
}
}
}
}
}
class CurrentSession: ObservableObject {
#Published var stats: Bool = false
#Published var user: String = "user"
}
struct TestObject: Identifiable, Codable {
let id = UUID()
}
Your issue is with:
NavigationView
There is only supposed to be one NavigationView in an entire view stack. Try removing the NavigationView from views B and C

(SwiftUI change detection) What is wrong with this piece of code?

When debugging an issue with an app I am working on, I managed to shrink it down to this minimal example:
class RadioModel: ObservableObject {
#Published var selected: Int = 0
}
struct RadioButton: View {
let idx: Int
#EnvironmentObject var radioModel: RadioModel
var body: some View {
Button(action: {
self.radioModel.selected = self.idx
}, label: {
if radioModel.selected == idx {
Text("Button \(idx)").background(Color.yellow)
} else {
Text("Button \(idx)")
}
})
}
}
struct RadioListTest: View {
#ObservedObject var radioModel = RadioModel()
var body: some View {
return VStack {
Text("You selected: \(radioModel.selected)")
RadioButton(idx: 0)
RadioButton(idx: 1)
RadioButton(idx: 2)
}.environmentObject(radioModel)
}
}
struct ContentView: View {
#State var refreshDate = Date()
func refresh() {
print("Refreshing...")
self.refreshDate = Date()
}
var body: some View {
VStack {
Text("\(refreshDate)")
HStack {
Button(action: {
self.refresh()
}, label: {
Text("Refresh")
})
RadioListTest()
}
}
}
}
This code looks pretty reasonable to me, although it exhibit a peculiar bug: when I hit the Refresh button, the radio buttons stop working. The radio buttons are not refreshed, and keep a reference to the old RadioModel instance, so when I click them they update that, and not the new one created after Refresh causes a new RadioListTest to be constructed. I suspect there is something wrong in the way I use EnvironmentObjects but I didn't find any reference suggesting that what I am doing is wrong. I know I could fix this particular problem in various ways that force a refresh in the radio buttons, but I would like to be able to understand which cases require a refresh forcing hack, I can't sprinkle the code with these just because "better safe than sorry", the performance is going to be hell if I have to redraw everything every time I make a modification.
edit: a clarification. The thing that is weird in my opinion and for which I would want an explanation, is this: why on refresh the RadioListTest is re-created (together with a new RadioModel) and its body re-evaluated but RadioButtons are created and the body properties are not evaluated, but the previous body is used. They both have only a view model as state, the same view model actually, but one have it as ObservedObject and the other as EnvironmentObject. I suspect it is a misuse of EnvironmentObject that I am doing, but I can't find any reference to why it is wrong
this works: (yes, i know, you know how to solve it, but i think this would be the "right" way.
problem is this line:
struct RadioListTest: View {
#ObservedObject var radioModel = RadioModel(). <<< problem
because the radioModel will be newly created each time the RadioListTest view is refreshed, so just create the instance one view above and it won't be created on every refresh (or do you want it to be created every time?!)
class RadioModel: ObservableObject {
#Published var selected: Int = 0
init() {
print("init radiomodel")
}
}
struct RadioButton<Content: View>: View {
let idx: Int
#EnvironmentObject var radioModel: RadioModel
var body: some View {
Button(action: {
self.radioModel.selected = self.idx
}, label: {
if radioModel.selected == idx {
Text("Button \(idx)").background(Color.yellow)
} else {
Text("Button \(idx)")
}
})
}
}
struct RadioListTest: View {
#EnvironmentObject var radioModel: RadioModel
var body: some View {
return VStack {
Text("You selected: \(radioModel.selected)")
RadioButton<Text>(idx: 0)
RadioButton<Text>(idx: 1)
RadioButton<Text>(idx: 2)
}.environmentObject(radioModel)
}
}
struct ContentView: View {
#ObservedObject var radioModel = RadioModel()
#State var refreshDate = Date()
func refresh() {
print("Refreshing...")
self.refreshDate = Date()
}
var body: some View {
VStack {
Text("\(refreshDate)")
HStack {
Button(action: {
self.refresh()
}, label: {
Text("Refresh")
})
RadioListTest().environmentObject(radioModel)
}
}
}
}
What is wrong with this piece of code?
Your RadioListTest subview is not updated on refresh() because it does not depend on changed parameter (refreshDate in this case), so SwiftUI rendering engine assume it is equal to previously created and does nothing with it:
HStack {
Button(action: {
self.refresh()
}, label: {
Text("Refresh")
})
RadioListTest() // << here !!
}
so the solution is to make this view dependent somehow on changed parameter, if it is required of course, and here fixed variant
RadioListTest().id(refreshDate)