2

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?

Parag Pawar
  • 827
  • 3
  • 12
  • 23
  • just show the headers in 1-direction scroll views and put the 2d scroll view inside, then connect the positions with ScrollViewReader – ChrisR Feb 17 '22 at 06:53

2 Answers2

13

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
    }
}
Bryan
  • 4,628
  • 3
  • 36
  • 62
ChrisR
  • 9,523
  • 1
  • 8
  • 26
  • Thanks, man, it works perfectly!! I just replaced `VStack` with `LazyVStack` as with 300 rows it was lagging. Initially, I did try with the preference key but wasn't get it to work properly. But with your implementation it did. Thanks again!! – Parag Pawar Feb 18 '22 at 06:03
  • @chrisR any idea how come the view will always start out already offset? (the print out shows offset >> (-556,.00.0) This makes the scrollview already scrolled to the middle instead of showing at the origin point (0.0, 0.0) Tx – app4g Feb 08 '23 at 12:37
  • Hmm.. after more testing, there's a behaviour difference between running it on the simulator on iOS 14.5 and iOS 16.2. with iOS61.2, the scroll starts out at the centre (offset) but with iOS14.5, it will start at (0.0,0.0). any ideas? – app4g Feb 08 '23 at 13:23
  • @app4g - I solved the centering/offset problem by using programmatic scrolling. I wrapped the ScrollView in table with a `ScrollViewReader { cellProxy in ... }`, then added an .onAppear function to scroll to the first cell using `cellProxy.scrollTo("0_0")` – corleyq Feb 22 '23 at 21:22
  • @corleyq I tot I tried that as well but it didn't stick. I will try again w your suggestion. – app4g Feb 23 '23 at 06:40
  • Great answer! However, if you have variable number of columns, say '3', where the total column width doesn't exceed the screen width, then you get some spacing between the `rowsHeader` and the start of `table`. The same with rows. (In these cases one doesn't need scrolling, but sometimes you just can't know the number of rows and/or cols). Any suggesting on 'forcing' the table left and up for small values of cols and rows? – GoZoner Mar 19 '23 at 23:19
2

This is a very minor edit to the awesome answer previously posted by @ChrisR. This addresses the issue noted by app4g whereby the scroll starts in the center of the chart rather than in the upper left corner. In the following code I've added programmatic scrolling to the table using ScrollViewReader with an .onAppear function to position the view in the first (upper left) cell (note: The ScrollViewReader requires iOS 14+).

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)
            }
            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) { 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) { row in
                Text("ROW \(row)")
                    .foregroundColor(.secondary)
                    .font(.caption)
                    .frame(width: 70, height: 50)
                    .border(Color.blue)
            }
        }
    }
    
    var table: some View {
        ScrollViewReader { cellProxy in
            ScrollView([.vertical, .horizontal]) {
                VStack(alignment: .leading, spacing: 0) {
                    ForEach(0..<rows) { row in
                        HStack(alignment: .top, spacing: 0) {
                            ForEach(0..<columns) { 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
                }
                
                // Use the following to scroll to the first cell (top leading corner of the table) when opened
                
                .onAppear {
                    cellProxy.scrollTo("0_0")
                }
            }
        }
    }
}


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
    }
}
corleyq
  • 93
  • 7
  • Thank you, this is a great recommendation. However with this solution I have a problem on ios 16.4 where when the screen first appears the rowsHeader scrollview is not scrolled to the top and the colsHeader scrollview is not scrolled to the left. When the table scrollview is first scrolled manually the rowsHeader and colsHeaders scrollviews jump to the correct offset. Do you have any suggestions? Thank you! – John Cashew Aug 05 '23 at 16:29
  • Hi John - Sorry for the late response, but I took most of the summer off and am just now seeing this. Not sure what the issue is that you're having with 16.4. I've loaded this test app (using this exact code) on an actual device using iOS 16.6 and it works as expected (automatically scrolls to the top left corner upon appearing). I've also used this same methodology in an actual app that I'm developing, and it works there as well. – corleyq Aug 19 '23 at 15:09