iosswiftui

Swift - Sticky Table


Does anyone know how to implement a view (table/grid/collection) like these?

Example Example 2

I have been at it for a while now and cannot figure it out. I'm pretty new to Swift which has not helped, but essentially the table should be horizontally and vertically scrollable (but not simultaneously, so there shouldn't be any "diagonal" scrolling), and should have a pinned header row (which "pushes" at the end of the table) and a pinned column.

I've seen this in two different apps which makes me think it should be relatively simple but I cannot seem to get it right. I have been using SwiftUI but maybe it is just too complex and I need to fall back to UIKit.

Here is something that feels close:

import SwiftUI

struct Team {
    let teamId: Int
    let city: String
    let name: String
}

struct ScrollableTableView: View {
    @State private var teams = [
        Team(teamId: 1610612737, city: "Atlanta", name: "Hawks"),
        Team(teamId: 1610612738, city: "Boston", name: "Celtics"),
        Team(teamId: 1610612739, city: "Cleveland", name: "Cavaliers"),
        Team(teamId: 1610612740, city: "New Orleans", name: "Pelicans")
    ]
    
    var body: some View {
        let columns = [
            GridItem(.fixed(120), alignment: .leading), // Pinned first column
            GridItem(.fixed(150), alignment: .leading),
            GridItem(.fixed(200), alignment: .leading)
        ]
        
        ScrollView([.vertical], showsIndicators: true) {
            ZStack {
                ScrollView([.horizontal]) {
                    LazyVGrid(columns: columns, alignment: .leading, pinnedViews: [.sectionHeaders]) {
                        // Pinned Header Section
                        Section(header: headerView) {
                            // Table Rows
                            ForEach(teams) { team in
                                Section(header: Text("") // Pinned column
                                    .frame(height: 40)
                                    .padding(.horizontal, 4)) {
                                        
                                        Text(team.city)
                                            .frame(height: 40)
                                            .padding(.horizontal, 4)
                                            .border(Color(.separator), width: 0.5)
                                        
                                        Text(team.name)
                                            .frame(height: 40)
                                            .padding(.horizontal, 4)
                                            .border(Color(.separator), width: 0.5)
                                    }
                                    .background(Color(.systemBackground))
                            }
                        }
                    }
                }
                
                
                
                LazyVGrid(columns: [columns[0]], alignment: .leading, pinnedViews: [.sectionHeaders]) {
                    Section(header: Text("Team ID")
                        .frame(width: 120, height: 40, alignment: .leading)
                        .bold()
                        .padding(.horizontal, 4)
                        .background(Color(.systemGray5))
                        .border(Color(.separator), width: 0.5)) {
                        ForEach(teams) { team in
                            Text(String(team.teamId)) // Pinned column
                                .frame(width: 120, height: 40)
                                .padding(.horizontal, 4)
                                .background(Color(.systemGray6))
                                .border(Color(.separator), width: 0.5)
                        }
                    }
                }
            }
        }
        .border(Color(.separator), width: 1)
    }
    
    private var headerView: some View {
        HStack(spacing: 0) {
            Text("")
                .frame(width: 120, height: 40, alignment: .leading)
                .bold()
                .padding(.horizontal, 4)
                .background(Color(.systemGray5))
                .border(Color(.separator), width: 0.5)
            
            Text("City")
                .frame(width: 150, height: 40, alignment: .leading)
                .bold()
                .padding(.horizontal, 4)
                .background(Color(.systemGray5))
                .border(Color(.separator), width: 0.5)
            
            Text("Name")
                .frame(width: 200, height: 40, alignment: .leading)
                .bold()
                .padding(.horizontal, 4)
                .background(Color(.systemGray5))
                .border(Color(.separator), width: 0.5)
        }
        .background(Color(.systemGray5))
    }
}

#Preview {
    ScrollableTableView()
}

Solution

  • Well if anyone stumbles across this, I came back and figured it out with some help from this post. Here is my solution:

    import SwiftUI
    
    struct Playground: View {
        @State private var headerOffset: CGPoint = .zero
        
        // Example headers and data
        let tableName: String = "Table"
        
        let rowHeaders: [String] =
            ["Row 1", "Row 2", "Row 3", "Row 4", "Row 5"]
        
        let columnHeaders: [String] =
            [" ", "Col 1", "Col 2", "Col 3", "Col 4"]
            
        let data: [[String]] = [
            [" ", "R1 C1", "R1 C2", "R1 C3", "R1 C4"],
            [" ", "R2 C1", "R2 C2", "R2 C3", "R2 C4"],
            [" ", "R3 C1", "R3 C2", "R3 C3", "R3 C4"],
            [" ", "R4 C1", "R4 C2", "R4 C3", "R4 C4"],
            [" ", "R5 C1", "R5 C2", "R5 C3", "R5 C4"]
        ]
        
        var body: some View {
            ScrollView([.vertical], showsIndicators: false) {
                ZStack {
                    LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
                        Section(header:
                            LazyHStack(spacing: 0) {
                                ForEach(columnHeaders, id: \.self) { header in
                                    HeaderCell(text: header)
                                }
                            }
                            .offset(x: headerOffset.x)
                        ) {
                            ScrollView([.horizontal], showsIndicators: false) {
                                // Data rows
                                ZStack(alignment: .topLeading) {
                                    LazyVStack(spacing: 0) {
                                        ForEach(data, id: \.self) { row in
                                            LazyHStack(spacing: 0) {
                                                ForEach(row, id: \.self) { cell in
                                                    DataCell(text: cell)
                                                }
                                            }
                                        }
                                    }
                                }
                                .coordinateSpace(name: "scroll")
                                .background( GeometryReader { geo in
                                    Color.clear
                                        .preference(key: ViewOffsetKey.self, value: geo.frame(in: .named("scroll")).origin)
                                })
                                .onPreferenceChange(ViewOffsetKey.self) { value in
                                    // Ensure only horizontal offset is tracked
                                    headerOffset.x = value.x
                                }
                            }
                        }
                    }
                    
                    // Sticky Column
                    LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
                        Section(header: HeaderCell(text: tableName)) {
                            ForEach(rowHeaders, id: \.self) { header in
                                HeaderCell(text: header)
                            }
                        }
                    }
                }
            }
        }
    }
    
    // MARK: - DataCell
    struct DataCell: View {
        let text: String
        
        var body: some View {
            Text(text)
                .frame(width: 100, alignment: .center)
                .padding()
                .background(Color.white)
                .foregroundColor(Color.black)
                .border(Color.gray, width: 1)
        }
    }
    
    // MARK: - HeaderCell
    struct HeaderCell: View {
        let text: String
        
        var body: some View {
            Text(text)
                .frame(width: 100, alignment: .center)
                .padding()
                .background(Color.gray)
                .border(Color.gray, width: 1)
        }
    }
    
    // MARK: - ViewOffsetKey
    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
        }
    }
    
    #Preview {
        Playground()
    }