swiftuiselectionnavigationsplitview

NavigationSplitView with selection loosing new selection when item is added


I am working on SwiftData project. I found the problem when adding new items to a NavigationSplitView that the first item was added without a problem. The selection was changed so that the Detail page was shown without any problem. But from then on it kept the old selection or was turned to nil, hence showing the view for a not selected item.

I tested it without SwiftData and was surprised, that it was a persistent problem. Here is the source code:

The data model is pretty simple:

struct Model: Identifiable, Hashable {
    var id: UUID
    var name: String
}

The content view with the navigation part is kept short:

import SwiftUI

struct ContentView: View {
    @State private var data: [Model] = [
        Model(id: UUID(), name: "Alfred"),
        Model(id: UUID(), name: "Bart"),
        Model(id: UUID(), name: "Cecille"),
        Model(id: UUID(), name: "Dave")]
   
    @State private var selectedItem: Model? {
        didSet {
            print("value: \(selectedItem?.name ?? "nil") 
                   from old value \(oldValue?.name ?? "nil")")
        }
    }

    // (1) for workaround
    
    var body: some View {
        NavigationSplitView {
            // label to view the value of selectedItem
            Label("\(selectedItem?.name ?? "nil")", systemImage: "hare.fill")
            List(selection: $selectedItem) {
                ForEach(data) { item in
                    NavigationLink(value: item) {
                        Label(item.name, systemImage: "cat")
                    }
                }
                
            }
        } detail: {
            if let selectedItem {
                DetailView(modelData: selectedItem)
            } else {
                Text("Select item, please!")
            }
            
        }
        .toolbar {
            ToolbarItem {
                Button(action: {
                    selectedItem = nil
                }) {
                    Label("set to nil", systemImage: "eye.trianglebadge.exclamationmark")
                }
            }
            
            ToolbarItem {
                Button(action: {
                    let newItem = Model(id: UUID(), name: "Frederic")
                    data.append(newItem)
                    selectedItem = newItem // (2) for workaround
                }) {
                    Label("Add Item", systemImage: "plus")
                }
            }
        }
    }
    // (3) for workaround
}

For better visualisation I made a short screen capture: https://youtu.be/FJpYjk336xM

I found a workaround but this seems a common problem, so what am I doing wrong?

The workaround was the following: adding a tempSelection at (1)

@State private var tempAddSelection: Model?

changing (2) to use the temporary selection

tempAddSelection = newItem

and adding an onChange statement to monitor the array

.onChange(of: data) {
   if let tempAddSelection {
       self.selectedItem = tempAddSelection
       self.tempAddSelection = nil
    }
}

But I did not find a 'correct' or best practice solution. I am not quite sure if this is a recent bug or not, or if there is a better solution. Ideas would be greatly appreciated, because I spent quite a while figuring it out as far as I came now.


Solution

  • It's highly recommended to use the ID type of Identifiable for the selection rather than the entire model type, because ForEach uses it internally (the inferred id parameter). And it fixes the issue

    struct Model: Identifiable, Hashable {
        let id = UUID()
        var name: String
    }
    
    struct TestView: View {
        @State private var data: [Model] = [
            Model(name: "Alfred"),
            Model(name: "Bart"),
            Model(name: "Cecille"),
            Model(name: "Dave")]
        
        @State private var selectedItem: Model.ID? {
            didSet {
                print("value: \(selectedItem) from old value \(oldValue)")
            }
        }
        
        // (1) for workaround
        
        var body: some View {
            NavigationSplitView {
                // label to view the value of selectedItem
                if let selectedModel {
                    Label(selectedModel.name, systemImage: "hare.fill")
                } else {
                    Text("Nothing selected")
                }
                List(selection: $selectedItem) {
                    ForEach(data) { item in
                        NavigationLink(value: item) {
                            Label(item.name, systemImage: "cat")
                        }
                    }
                    
                }
            } detail: {
                if let selectedModel {
                    DetailView(modelData: selectedModel)
                } else {
                    Text("Select item, please!")
                }
                
            }
            .toolbar {
                ToolbarItem {
                    Button(action: {
                        selectedItem = nil
                    }) {
                        Label("set to nil", systemImage: "eye.trianglebadge.exclamationmark")
                    }
                }
                
                ToolbarItem {
                    Button(action: {
                        let newItem = Model(name: "Frederic")
                        data.append(newItem)
                        selectedItem = newItem.id
                    }) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        }
        
        private var selectedModel : Model? {
            guard let selectedItem else { return nil }
            return data.first(where: {$0.id == selectedItem})
        }
    }