Issue I’m encountering an issue in SwiftUI where modifying a value-type model inside a child view sometimes causes the navigation stack to pop unexpectedly. This happens even though I'm only modifying a property inside the child view, not replacing the entire model.
Context I have a list of items in a NavigationStack, where selecting an item navigates to a detail view. The detail view allows editing the name property via a TextField.
Here’s the simplified code:
import SwiftUI
struct Item: Identifiable, Hashable {
let id: UUID
var name: String
}
struct ContentView: View {
@State private var items = [
Item(id: UUID(), name: "Item 1"),
Item(id: UUID(), name: "Item 2")
]
var body: some View {
NavigationStack {
List($items) { $item in
NavigationLink(item.name, value: item)
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}
}
}
struct DetailView: View {
@State var item: Item // <- Local copy of the item
var body: some View {
VStack {
TextField("Edit Name", text: $item.name)
.textFieldStyle(.roundedBorder)
.padding()
}
.navigationTitle(item.name)
}
}
Problem
When I type in the TextField, the view sometimes pops back to the previous screen without pressing the back button. It seems like the navigation stack is losing track of my Item instance when its name is updated.
- Used @Binding in DetailView
- Modified DetailView to accept a @Binding var item: Item instead of using @State.
- This caused a compiler error because NavigationDestination passes a copied value, not a binding.
- Wrapped Item in a Reference Type (class)
- Converted Item from a struct to a class and used @ObservableObject.
- This fixed the navigation pop issue but introduced complications with Codable, Hashable, and thread safety.
- Tracked the Selected Item Separately
Introduced an explicit @State private var selectedItem: Item? in ContentView.
Manually assigned the selected item before navigation.
This worked but felt like a hack rather than a SwiftUI-friendly approach.
Expected Behavior Updating item.name inside DetailView should only update that field and not cause SwiftUI to pop the navigation stack.
Actual Behavior Changing item.name sometimes causes the view to pop unexpectedly, as if SwiftUI lost track of the navigation state.
Issue I’m encountering an issue in SwiftUI where modifying a value-type model inside a child view sometimes causes the navigation stack to pop unexpectedly. This happens even though I'm only modifying a property inside the child view, not replacing the entire model.
Context I have a list of items in a NavigationStack, where selecting an item navigates to a detail view. The detail view allows editing the name property via a TextField.
Here’s the simplified code:
import SwiftUI
struct Item: Identifiable, Hashable {
let id: UUID
var name: String
}
struct ContentView: View {
@State private var items = [
Item(id: UUID(), name: "Item 1"),
Item(id: UUID(), name: "Item 2")
]
var body: some View {
NavigationStack {
List($items) { $item in
NavigationLink(item.name, value: item)
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}
}
}
struct DetailView: View {
@State var item: Item // <- Local copy of the item
var body: some View {
VStack {
TextField("Edit Name", text: $item.name)
.textFieldStyle(.roundedBorder)
.padding()
}
.navigationTitle(item.name)
}
}
Problem
When I type in the TextField, the view sometimes pops back to the previous screen without pressing the back button. It seems like the navigation stack is losing track of my Item instance when its name is updated.
- Used @Binding in DetailView
- Modified DetailView to accept a @Binding var item: Item instead of using @State.
- This caused a compiler error because NavigationDestination passes a copied value, not a binding.
- Wrapped Item in a Reference Type (class)
- Converted Item from a struct to a class and used @ObservableObject.
- This fixed the navigation pop issue but introduced complications with Codable, Hashable, and thread safety.
- Tracked the Selected Item Separately
Introduced an explicit @State private var selectedItem: Item? in ContentView.
Manually assigned the selected item before navigation.
This worked but felt like a hack rather than a SwiftUI-friendly approach.
Expected Behavior Updating item.name inside DetailView should only update that field and not cause SwiftUI to pop the navigation stack.
Actual Behavior Changing item.name sometimes causes the view to pop unexpectedly, as if SwiftUI lost track of the navigation state.
2 Answers
Reset to default 0State
should always be private
Since the navigationDestination
by Type
does not support Binding
you should use the one wirh isPresented
struct RowView: View {
@State private var showDetails = false
@Binding var item: Item
var body: some View {
Button("Show details") {
showDetails = true
}
.navigationDestination(isPresented: $showDetails) {
DetailView(item: $item)
}
}
- Remove
Hashable
fromItem
. - Change
value:
toitem.id
. - Change destination type to
Item.ID.self
. - Compute a binding from the id to the item in the destination closure and use
@Binding
inDetailView
. See sample code below that implements all of this:
The reason it has to be done this way is NavigationStack
has a path and the NavigationLink
is like a button that adds the value to the path. It can't update an existing path value when the value changes, it can't find it because its whole hash has changed, instead it has to remove the value and add the new one. That is what is causing the pop and push you sometimes see when the name property of the item struct is changed. The name
should not be part of the hash that is used to identify values in the path. So the solution is to make the value the id instead, so that the same id
can stay in the path when the corresponding item's name
changes.
The other problem with a whole item in the path is the source of truth is wrong. Because you are trying to go from the state to the nav path to the destination, where as it should directly flow from the state to the destination so that a new binding can be computed from the state whenever any item is changed, and hence the state changes and body is called which recalculates a replacement destination for the same path value.
This pattern also allows you to use an .onChange(items.map(\.ids))
and you can programatically pop the nav path if the id is no longer contained in the items, e.g. it has been deleted somewhere. I'll add that as a second sample.
import SwiftUI
struct Item: Identifiable {
let id: UUID
var name: String
}
struct ContentView: View {
@State private var items = [
Item(id: UUID(), name: "Item 1"),
Item(id: UUID(), name: "Item 2")
]
var body: some View {
NavigationStack {
List(items) { item in
NavigationLink(item.name, value: item.id)
}
.navigationDestination(for: Item.ID.self) { id in
if let index = items.firstIndex(where: { $0.id == id }) {
DetailView(item: $items[index])
}
}
}
}
}
struct DetailView: View {
@Binding var item: Item // <- Local copy of the item
var body: some View {
VStack {
TextField("Edit Name", text: $item.name)
.textFieldStyle(.roundedBorder)
.padding()
}
.navigationTitle(item.name)
}
}
Version with programatic pop:
import SwiftUI
struct Item: Identifiable {
let id: UUID
var name: String
}
struct ContentView: View {
@State var path: [Item.ID] = []
@State private var items = [
Item(id: UUID(), name: "Item 1"),
Item(id: UUID(), name: "Item 2")
]
var body: some View {
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(item.name, value: item.id)
}
.navigationDestination(for: Item.ID.self) { id in
if let index = items.firstIndex(where: { $0.id == id }) {
DetailView(item: $items[index])
}
}
}
.onChange(of: items.map(\.id)) { _, newIDs in
// clear path if the detail item was deleted
if let first = path.first {
if !newIDs.contains(first) {
path = []
}
}
}
}
}
struct DetailView: View {
@Binding var item: Item // <- Local copy of the item
var body: some View {
VStack {
TextField("Edit Name", text: $item.name)
.textFieldStyle(.roundedBorder)
.padding()
}
.navigationTitle(item.name)
}
}