iosswiftuiswiftui-scrollview

SwiftUI: Pin headers in scrollview which has vertical and horizontal scroll in excel like view


I created an excel-like view, using a multi-directional scroll view. Now I want to pin the headers, not only the column headers but the row headers as well. Look at the following gif:

enter image description here

Code I used to create this view:

        ScrollView([.vertical, .horizontal]){
            VStack(spacing: 0){
                ForEach(0..<model.rows.count+1, id: \.self) {rowIndex in
                    
                    HStack(spacing: 0) {
                        ForEach(0..<model.columns.count+1) { columnIndex in
                            
                            if rowIndex == 0 && columnIndex == 0 {
                                Rectangle()
                                    .fill(Color(UIColor(Color.white).withAlphaComponent(0.0)))
                                    .frame(width: CGFloat(200).pixelsToPoints(), height: CGFloat(100).pixelsToPoints())
                                    .padding([.leading, .trailing])
                                    .border(width: 1, edges: [.bottom, .trailing], color: .blue)
                            } else if (rowIndex == 0 && columnIndex > 0) {
                                TitleText(
                                    label: model.columns[columnIndex - 1].label,
                                    columnWidth: CGFloat(columnWidth).pixelsToPoints(),
                                    borderEgdes: [.top, .trailing, .bottom]
                                )
                            } else if (rowIndex > 0 && columnIndex == 0) {
                                TitleText(
                                    label: model.rows[rowIndex - 1].label,
                                    columnWidth: CGFloat(columnWidth).pixelsToPoints(),
                                    borderEgdes: [.trailing, .bottom, .leading]
                                )
                            } else if (rowIndex > 0){
                                //text boxes
                                let column = model.columns[columnIndex - 1]
                                switch column.type {
                                case "Text":
                                    MatrixTextField(keyboardType: .default)
                                case "Number":
                                    MatrixTextField(keyboardType: .decimalPad)
                                case "RadioButton":
                                    RadioButton()
                                case "Checkbox":
                                    MatrixCheckbox()
                                default:
                                    MatrixTextField(keyboardType: .default)
                                }
                                
                            }
                            
                        }
                    }
                    
                }
            }
        }
        .frame(maxHeight: 500)

Is it possible to pin both column and row headers here?

It's important I use VStack and HStack only instead of LazyVStack and LazyHStack, as I need smoothness while scrolling, when I use Lazy stacks, it jitters a lot for obvious reasons. So cannot really use section headers here.

What other approach could I follow?


Solution

  • It was a little more complex than expected. You have to use .preferenceKey to align all three ScollViews. Here is a working example:

    enter image description here

    struct ContentView: View {
        
        let columns = 20
        let rows = 30
        
        @State private var offset = CGPoint.zero
        
        var body: some View {
            
            HStack(alignment: .top, spacing: 0) {
                
                VStack(alignment: .leading, spacing: 0) {
                    // empty corner
                    Color.clear.frame(width: 70, height: 50)
                    ScrollView([.vertical]) {
                        rowsHeader
                            .offset(y: offset.y)
                    }
                    .disabled(true)
                    .scrollIndicators(.hidden)
                }
                VStack(alignment: .leading, spacing: 0) {
                    ScrollView([.horizontal]) {
                        colsHeader
                            .offset(x: offset.x)
                    }
                    .disabled(true)
    
                    table
                        .coordinateSpace(name: "scroll")
                }
            }
            .padding()
        }
        
        var colsHeader: some View {
            HStack(alignment: .top, spacing: 0) {
                ForEach(0..<columns, id: \.self) { col in
                    Text("COL \(col)")
                        .foregroundColor(.secondary)
                        .font(.caption)
                        .frame(width: 70, height: 50)
                        .border(Color.blue)
                }
            }
        }
        
        var rowsHeader: some View {
            VStack(alignment: .leading, spacing: 0) {
                ForEach(0..<rows, id: \.self) { row in
                    Text("ROW \(row)")
                        .foregroundColor(.secondary)
                        .font(.caption)
                        .frame(width: 70, height: 50)
                        .border(Color.blue)
                }
            }
        }
        
        var table: some View {
            ScrollView([.vertical, .horizontal]) {
                VStack(alignment: .leading, spacing: 0) {
                    ForEach(0..<rows, id: \.self) { row in
                        HStack(alignment: .top, spacing: 0) {
                            ForEach(0..<columns, id: \.self) { col in
                                // Cell
                                Text("(\(row), \(col))")
                                    .frame(width: 70, height: 50)
                                    .border(Color.blue)
                                    .id("\(row)_\(col)")
                            }
                        }
                    }
                }
                .background( GeometryReader { geo in
                    Color.clear
                        .preference(key: ViewOffsetKey.self, value: geo.frame(in: .named("scroll")).origin)
                })
                .onPreferenceChange(ViewOffsetKey.self) { value in
                    print("offset >> \(value)")
                    offset = value
                }
            }
        }
        
    }
    
    struct ViewOffsetKey: PreferenceKey {
        typealias Value = CGPoint
        static var defaultValue = CGPoint.zero
        static func reduce(value: inout Value, nextValue: () -> Value) {
            value.x += nextValue().x
            value.y += nextValue().y
        }
    }