swiftswiftuiswiftui-navigationlinkswiftui-navigationstackswiftui-state

Navigation state restoration with NavigationStack breaks binding


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.


Solution

  • 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:

    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
            }
        }
    }