Does anyone know how to implement a view (table/grid/collection) like these?
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()
}
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()
}