I'm developing an iOS app that lists items in a List inside a NavigationStack. When the user clicks on a item it shows its details, and if he clicks then on the Edit button, he can edit its values (on yet another view). Updating any value refreshes the values on the detail view and the list.
All works fine, except when the I kill the app while being on the edition view, then, on the next execution of the app, the NavigationStack properly restores the state and the user is taken back to the edition view, but if I then change the item name and click on save, the changes get saved but do not get updated in neither the details view not the list view. It's like if the binding were broken, but if I go back to the details view or the list and manually navigate again to the edition view, then the binding works again and any edited value is shown on the details and list views.
Below is a simplification of my code:
@Observable class Item : Hashable, Equatable, Codable {
var name:String
init(_ name: String) {
self.name = name
}
static func == (lhs: Item, rhs: Item) -> Bool {
return lhs.name == rhs.name
}
public func hash(into hasher: inout Hasher) {
hasher.combine(name)
}
}
@Observable class NavPathStore {
private let savePath = URL.documentsDirectory.appending(path: "SavedPathStore")
public var path = NavigationPath() {
didSet {
save()
}
}
init() {
if let data = try? Data(contentsOf: savePath) {
if let decoded = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) {
path = NavigationPath(decoded)
return
}
}
}
func save() {
guard let representation = path.codable else { return }
do {
let data = try JSONEncoder().encode(representation)
try data.write(to: savePath)
} catch {
print("Failed to save navigation data")
}
}
}
@main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
@State var navigation = NavPathStore()
@State var items: [Item] = [Item("First"), Item("Second")]
var body: some View {
NavigationStack(path: $navigation.path) {
List {
ForEach(items, id:\.name) { item in
NavigationLink(value: Navigation_DetailView(item: item)) {
Text("Item \(item.name)")
}
}
}
.navigationDestination(for: Navigation_DetailView.self) { nav in
DetailView(item: nav.item)
}
}
}
}
struct Navigation_DetailView: Hashable, Equatable, Codable {
var item: Item
}
struct Navigation_EditView: Hashable, Equatable, Codable {}
struct DetailView: View {
let item: Item
var body: some View {
Text("Details for item \(item.name)")
.toolbar {
NavigationLink("Edit", value: Navigation_EditView())
}
.navigationDestination(for: Navigation_EditView.self) { nav in
EditView(item: self.item)
}
}
}
struct EditView : View {
@Environment(\.dismiss) private var dismiss
let item: Item
@State private var name: String
init(item: Item) {
self.item = item
self.name = item.name
}
var body: some View {
VStack {
TextField("Name", text: $name)
.textFieldStyle(.roundedBorder)
.padding()
Button("Save") {
item.name = self.name
dismiss()
}
}
.navigationTitle("Item Edition")
}
}
I suspect the problem is related to the way I'm calling the NavigationLinks or the way i'm storing navigation with the NavPathStore class.
How should navigation be implemented so that bindings do not break?
EDIT: I've edited the question to provide a minimal reproducible example. EDIT 2: Edited again to better clarify how to reproduce the problem.
You initialised a @State
in EditView.init
. You should initialise name
to some constant string, then set it to the desired value in onAppear
. See this discussion.
@State private var name: String = ""
// ...
.onAppear {
name = item.name
}
Now the change is updated in DetailView
, but ContentView
will still display the initial names of the items. This is because the Item
object that is in the navigation path is a totally different object from those in here:
@State var items: [Item] = [Item("First"), Item("Second")]
When you decode the stored navigation path, you indirectly created an Item
object, which is stored in Navigation_DetailView
. In ContentView
, you further create 2 more Item
objects because of the line above.
You never actually save the two Item
objects in a file. You only save whatever Item
is on the navigation path, which obviously is not going to save both items.
You need to drastically change the design. Here is an outline of how you would do this:
Item
s in the navigation path to the Item
objects in SavedItemsStore. You can find which items correspond to each other by matching their id
, where id
is a new property that you will add to Item
. Or you can just match their name
s.Item
s can be read.As an illustrative example:
@Observable class Item : Identifiable, Hashable, Equatable, Codable {
var name:String
let id: UUID
init(_ name: String, _ id: UUID = UUID()) {
self.name = name
self.id = id
}
static func == (lhs: Item, rhs: Item) -> Bool {
return lhs.name == rhs.name && lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(name)
hasher.combine(id)
}
}
@Observable class Storage {
private let savePath = URL.documentsDirectory.appending(path: "SavedPathStore")
private let itemsSavePath = URL.documentsDirectory.appending(path: "SavedItemsStore")
var path = [Navigation]()
var items = [Item]()
init() {
print(savePath)
let decoder = JSONDecoder()
if let data = try? Data(contentsOf: itemsSavePath) {
if let decoded = try? decoder.decode([Item].self, from: data) {
items = decoded
}
} else {
items = [Item("First"), Item("Second")]
}
if let data = try? Data(contentsOf: savePath) {
if let decoded = try? decoder.decode([Navigation].self, from: data) {
path = decoded.compactMap {
switch $0 {
case .detail(let item):
items.first { $0.id == item.id }.map { .detail($0) }
case .edit(let item):
items.first { $0.id == item.id }.map { .edit($0) }
}
}
}
}
}
func save() {
do {
let data = try JSONEncoder().encode(path)
try data.write(to: savePath)
let itemsData = try JSONEncoder().encode(items)
try itemsData.write(to: itemsSavePath)
} catch {
print("Failed to save data")
}
}
}
struct ContentView: View {
@State var storage = Storage()
@State var items: [Item] = []
var body: some View {
NavigationStack(path: $storage.path) {
List {
ForEach(items, id:\.name) { item in
NavigationLink(value: Navigation.detail(item)) {
Text("Item \(item.name)")
}
}
}
.navigationDestination(for: Navigation.self) { nav in
switch nav {
case .detail(let item):
DetailView(item: item)
case .edit(let item):
EditView(item: item)
}
}
}
.onAppear {
items = storage.items
}
.onChange(of: storage.path) {
storage.save()
}
}
}
enum Navigation: Hashable, Codable {
case detail(Item)
case edit(Item)
}
struct DetailView: View {
let item: Item
var body: some View {
Text("Details for item \(item.name)")
.toolbar {
NavigationLink("Edit", value: Navigation.edit(item))
}
}
}
struct EditView : View {
@Environment(\.dismiss) private var dismiss
let item: Item
@State private var name: String = ""
init(item: Item) {
self.item = item
}
var body: some View {
VStack {
TextField("Name", text: $name)
.textFieldStyle(.roundedBorder)
.padding()
Button("Save") {
item.name = self.name
dismiss()
}
}
.navigationTitle("Item Edition")
.onAppear {
name = item.name
}
}
}