Search code examples
swiftuiswiftui-scrollviewswiftui-gridrow

Make rows of SwiftUI Grid 'scrollable'


I have SwiftUI grid of 25 rows with a first header row and a totals row at the bottom. I would like the header and the totals row to remain fixed, but allow the user to scroll the 23 rows (as they often do not fit the screen). Using ScrollView on certain GridRows does not work...

import SwiftUI

struct ContentView: View {
    var body: some View {
        Grid {
            GridRow {
                Text("Name")
                Text("Age")
            }
            .font(.headline)
            
            // would like to make the following 'scrollable'
            GridRow {
                Text("Marc")
                Text("25")
            }
            
            GridRow {
                Text("Francis")
                Text("36")
            }
            // ... many more GridRows ...
            
            // end of scrolling
            
            GridRow {
                Text("Total Age:")
                Text("61")
            }
            
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Solution

  • I assume you want the scrollable rows to scroll vertically, right?

    Some possible approaches:

    • An easy solution would be to use fixed widths for the columns. Then the grid can simply be built using a VStack with rows of HStack. Or, instead of HStack for the rows, you could consider using a custom Layout that uses percentages or weights for the column widths. An example of such a layout can be found in the answer to SwiftUI How to Set Specific Width Ratios for Child Elements in an HStack (it was my answer).

    • If you were to use a LazyVGrid then you could put the titles in the header and the totals in the footer and pin the header and footer. But that still leaves the issue of alignment.

    • You could also consider using a Table, which automatically adds scrolling. However, when I tried it on iOS, it seems the column headers are automatically hidden if scrolling is needed and there is no way to force them to be shown. Even applying .tableColumnHeaders(.visible) doesn't help. Also, I don't think there is any concept of having a footer row at the end. So for iOS, a Table is probably not useful.


    Here is another approach that keeps it all dynamic:

    • Remove the header and footer rows and show these outside the Grid.
    • The Grid itself can then be wrapped in a ScrollView.
    • To align the header and footer items, the widths of the columns can be measured by attaching an .onGeometryChange modifier to each cell.
    • The maximum width of the cells in a column is stored in an array.
    • If you are expecting that any of the title or footer items will have the greatest width then you could use the technique in reverse and set the width of a column from the width of a title or footer item.

    Here is an example to show it working. The width of the first column (Name) is determined by the grid contents, the width of the second column (Age) is determined by the title.

    struct ContentView: View {
        private let spacing: CGFloat = 10
        @State private var colWidths = [CGFloat](repeating: 0, count: 2)
    
        var body: some View {
            VStack {
                HStack(spacing: spacing) {
                    Text("Name")
                        .frame(width: colWidths[0], alignment: .leading)
                    Text("Age")
                        .modifier(WidthRecorder(maxWidth: $colWidths[1]))
                }
                .font(.headline)
    
                ScrollView {
                    Grid(alignment: .leading, horizontalSpacing: spacing) {
                        GridRow {
                            Text("Marc")
                                .modifier(WidthRecorder(maxWidth: $colWidths[0]))
                            Text("25")
                                .frame(width: colWidths[1], alignment: .leading)
                        }
                        GridRow {
                            Text("Francis")
                                .modifier(WidthRecorder(maxWidth: $colWidths[0]))
                            Text("36")
                                .frame(width: colWidths[1], alignment: .leading)
                        }
                        ForEach(1..<50) { i in
                            GridRow {
                                Text("Another row \(i)")
                                    .modifier(WidthRecorder(maxWidth: $colWidths[0]))
                                Text("\(i)")
                                    .frame(width: colWidths[1], alignment: .leading)
                            }
                        }
                    }
                }
                HStack(spacing: spacing) {
                    Text("Total Age:")
                        .frame(width: colWidths[0], alignment: .leading)
                    Text("61")
                        .frame(width: colWidths[1], alignment: .leading)
                }
            }
        }
    
        struct WidthRecorder: ViewModifier {
            @Binding var maxWidth: CGFloat
            func body(content: Content) -> some View {
                content
                    .onGeometryChange(for: CGFloat.self) { proxy in
                        proxy.size.width
                    } action: { newVal in
                        if newVal > maxWidth {
                            maxWidth = newVal
                        }
                    }
            }
        }
    }
    

    Animation