swiftuinavigationsplitview

how to have selection for every table for each item in sidebar in NavigationSplitView


I have an array of sessions. Each session has an array of records. There are sessions in NavigationSplitView in sidebar. There is a table of records in detail view. When user click on a session new table is shown in detail view. I want to keep record selection for every table/session. For now if I select 5th row in 1st session and then switch to 2nd session 5th row gets selected.

struct ContentView: View {
    @ObservedObject var model: Model
    @State var selection: TestSession.ID?

    var body: some View {
        if model.sessions.count > 0 {
            NavigationSplitView(sidebar: {
                List(selection: $selection) {
                    ForEach(model.sessions, id: \.id) { session in
                        Text("\(session.name)")
                    }
                }
            }, detail: {
                if selection != nil {
                    let session = model[selection!]
                    if session != nil {
                        SessionView(model: model, session: session!, selection: session!.selectedRecord)
                    }
                }
            })
        } else {
            Text("No sessions")
        }
    }
}

struct SessionView: View {
    @ObservedObject var model: Model
    var session: TestSession
    @State var selection: TestRecord.ID?

    var body: some View {
        VStack {
            Text("Session \(session.id) - \(session.name) - \(session.records.count)")
            Text("Selected row: \(session.selectedRecord)")
            Table(of: TestRecord.self, selection: $selection) {
                TableColumn("Id") { rec in
                    Text("\(String(rec.id))")
                    .font(.system(.body, design: .monospaced))
                }
                TableColumn("Value") { rec in
                    Text("\(String(rec.value))")
                    .font(.system(.body, design: .monospaced))
                }
            } rows: {
                ForEach(session.records) { rec in
                    TableRow(rec)
                }
            }

            Spacer()
        }
        .onChange(of: selection) { oldValue, newValue in
            model.setSelectedRecord(selection, for: session.id)
        }
    }
}

class Model: ObservableObject {
    @Published var sessions: [TestSession] = []

    init() {
        let n = Int.random(in: 5..<10)
        for i in 0..<n {
            var s = TestSession(name: "session \(i + 1)")
            let m = Int.random(in: 10..<20)
            for j in 0..<m {
                s.addRecord("\(j + 1)")
            }
            self.sessions.append(s)
        }
    }

    subscript(index: UUID) -> TestSession? {
        for i in 0..<self.sessions.count {
            if self.sessions[i].id == index {
                return self.sessions[i]
            }
        }
        return nil
    }

    func setSelectedRecord(_ index: Int?, for session: TestSession.ID) {
        for i in 0..<self.sessions.count {
            if self.sessions[i].id == session {
                return self.sessions[i].selectedRecord = index
            }
        }
    }
}

struct TestSession: Identifiable
{
    let id = UUID()
    let name: String
    var records: [TestRecord] = []
    var selectedRecord: Int?

    init(name: String) {
        self.name = name
    }

    mutating func addRecord(_ value: String) {
        let r = TestRecord(id: self.records.count, value: String(self.records.count))
        self.records.append(r)
    }
}

struct TestRecord: Identifiable {
    let id: Int
    let value: String
}

I save selected row in session structure but cannot restore it when table is loaded.


Solution

  • When you do

    SessionView(model: model, session: session!, selection: session!.selectedRecord)
    

    You are initialising a @State of SessionView (selection) from outside. This almost always does not do what you intend to do. Initialising @State only works once throughout a view's lifetime. After that, it will stay unchanged until something in SessionView changes it. Changing the navigation split view side bar list selection won't change it.

    This is why you should always initialise @State inline, using a property initialiser. This is also why @State should be marked private, so that you don't accidentally initialise it via a parameter.

    Since selection comes from the outside and SessionView can change it, it should be a @Binding, not @State.

    struct SessionView: View {
        @Binding var selection: TestRecord.ID?
        ...
    }
    

    Instead of setSelectedRecord, move that logic into the setter of the subscript. This allows you to form a Binding from the subscript.

    // in Model...
    subscript(index: UUID) -> TestSession? {
        get {
            sessions.first(where: { $0.id == index })
        }
        set {
            if let newValue, let i = sessions.firstIndex(where: { $0.id == newValue.id }) {
                sessions[i] = newValue
            }
        }
    }
    
    // in ContentView, this is how you would pass the Binding
    detail: {
        if let selection, let session = Binding($model[selection]) {
            SessionView(model: model, session: session.wrappedValue, selection: session.selectedRecord)
        }
    }
    

    You can now remove the .onChange(of: selection) modifier as well.


    Alternatively, you can add a non-@State property to SessionView:

    @State private var selection: TestRecord.ID?
    let selectedRecord: TestRecord.ID? // ContentView can pass session.selectedRecord to this
    
    detail: {
        if let selection, let session = model[selection] {
            SessionView(model: model, session: session, selectedRecord: session.selectedRecord)
        }
    }
    

    Then you can use onChange in SessionView to manually update this:

    .onChange(of: selectedRecord) { _, newValue in
        selection = newValue
    }
    .onChange(of: selection) { _, newValue in
        model.setSelectedRecord(newValue, for: session.id)
    }