swiftmacosswiftui

Get number of rows visible in SwiftUI Table


I want to be able to programatically select a row in a SwiftUI Table that's one page away from the current one (for example on PageDown/PageUp keypress).

How can I check how many rows fit in a single page?

Alternatively, how can I check the height of a row? With that I can divide the height of the whole table by the height of the row to get an estimate of the number of visible rows.


Solution

  • Suppose your table displays a list of Items like this:

    struct Item: Identifiable, Hashable {
        let id: Int
    }
    

    You can use a preference key like this to track which items are visible:

    struct VisibleRowsKey: PreferenceKey {
        static let defaultValue: [Int: Bool] = [:]
        
        static func reduce(value: inout [Int : Bool], nextValue: () -> [Int : Bool]) {
            value.merge(nextValue(), uniquingKeysWith: { $1 })
        }
    }
    

    If you only care about how many items are visible as you said in the title, you can change this to a [Bool] instead of [Int: Bool]. Though, if you want to select the next invisible item, surely you want to know the ID of the last visible item, so that you can figure out the ID of the next invisible item?

    Then, you can set the preference value in one of the table columns:

    GeometryReader { geo in
        Table(items, selection: $selection) {
            TableColumn("ID") { item in
                Text(item.id.description)
                    .anchorPreference(key: VisibleRowsKey.self, value: .bounds) { anchor in
                        let bounds = geo[anchor]
                        let visible = let visible = bounds.minY > 0 && bounds.maxY < geo.size.height
                        return [item.id: visible]
                    }
            }
        }
    }
    

    Note that the bounds is not the bounds of the row, but just the bounds of the Text, which is sufficient to determine if a row is visible.

    Then you can filter out the invisible items in onPreferenceChange.

    .onPreferenceChange(VisibleRowsKey.self) { value in
        visibleItems = Array(value.filter { $1 }.keys)
    }
    

    To scroll to and select the next invisible item, you can find the last visible item and find the next item after it. I assume the item's IDs are ordered in some way. If not, you can always just do firstIndex(of:).

    Here is a minimal example of a table displaying items with IDs 0 to 99. The last visible item is simply the one with the highest ID, and the next item can be found just by adding 1.

    struct ContentView: View {
        
        @State private var items = (0..<100).map { Item(id: $0) }
        @State private var visibleItems = [Int]()
        @State private var selection: Int?
        
        var body: some View {
            ScrollViewReader { scroll in
                HStack {
                    GeometryReader { geo in
                        Table(items, selection: $selection) {
                            TableColumn("ID") { item in
                                Text(item.id.description)
                                    .anchorPreference(key: VisibleRowsKey.self, value: .bounds) { anchor in
                                        let bounds = geo[anchor]
                                        let visible = bounds.minY > 0 && bounds.maxY < geo.size.height
                                        return [item.id: visible]
                                    }
                            }
                        }
                    }
                    Divider()
                    Button("Next Page") {
                        if let lastVisible = visibleItems.max() {
                            scroll.scrollTo(lastVisible + 1, anchor: .top)
                            selection = lastVisible + 1
                        }
                    }
                }
            }
            .onPreferenceChange(VisibleRowsKey.self) { value in
                visibleItems = Array(value.filter { $1 }.keys)
            }
        }
    }
    

    As you can probably tell, doing this with SwiftUI is not very efficient. If that is a concern, I would recommend dropping down to AppKit and use a NSTableView. Then the number of visible rows is just:

    tableView.rows(in: tableView.visibleRect).length