Last active
September 9, 2024 13:46
-
-
Save natpenguin/e6722b9a0841ba1430a978634e9dfcc8 to your computer and use it in GitHub Desktop.
A view like spreadsheet in SwiftUI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
struct SpreadsheetView<ColumnView: View, RowView: View, ContentView: View, Column: Hashable, Row: Hashable>: View { | |
init( | |
columns: [Column], | |
rows: [Row], | |
columnWidth: CGFloat, | |
columnHeight: CGFloat, | |
rowWidth: CGFloat, | |
rowHeight: CGFloat, | |
columnView: @escaping (Column) -> ColumnView, | |
rowView: @escaping (Row) -> RowView, | |
contentView: @escaping (Column, Row) -> ContentView | |
) { | |
self.columns = columns | |
self.rows = rows | |
self.columnWidth = columnWidth | |
self.columnHeight = columnHeight | |
self.rowWidth = rowWidth | |
self.rowHeight = rowHeight | |
self.columnCount = columns.count | |
self.rowCount = rows.count | |
self.columnRange = 0..<columnCount | |
self.rowRange = 0..<rowCount | |
self.columnView = columnView | |
self.rowView = rowView | |
self.contentView = contentView | |
} | |
@State var scrollOffset: CGPoint = .zero | |
let columns: [Column] | |
let rows: [Row] | |
let columnWidth: CGFloat | |
let columnHeight: CGFloat | |
let rowWidth: CGFloat | |
let rowHeight: CGFloat | |
let columnCount: Int | |
let rowCount: Int | |
private let columnRange: Range<Int> | |
private let rowRange: Range<Int> | |
@ViewBuilder let columnView: (Column) -> ColumnView | |
@ViewBuilder let rowView: (Row) -> RowView | |
@ViewBuilder let contentView: (Column, Row) -> ContentView | |
var contentSize: CGSize { | |
.init( | |
width: (columnWidth * CGFloat(columnCount)) + rowWidth, | |
height: (rowHeight * CGFloat(rowCount)) + columnHeight | |
) | |
} | |
var body: some View { | |
ZStack { | |
HStack(spacing: 0) { | |
leftContentView() | |
rightContentView() | |
} | |
ObservableScrollView( | |
axis: [.vertical, .horizontal], | |
scrollOffset: $scrollOffset | |
) { proxy in | |
Color.clear | |
.frame( | |
width: contentSize.width, | |
height: contentSize.height | |
) | |
} | |
.opacity(0.5) | |
} | |
} | |
func leftContentView() -> some View { | |
VStack(spacing: 0) { | |
Color.white | |
.frame( | |
width: rowWidth, | |
height: columnHeight | |
) | |
ScrollView(.vertical) { | |
VStack(spacing: 0) { | |
ForEach(rows, id: \.self) { row in | |
rowView(row) | |
.frame( | |
width: rowWidth, | |
height: rowHeight | |
) | |
} | |
} | |
.offset(y: scrollOffset.y) | |
} | |
.disabled(true) | |
} | |
} | |
func rightContentView() -> some View { | |
VStack(spacing: 0) { | |
ScrollView(.horizontal) { | |
HStack(spacing: 0) { | |
ForEach(columns, id: \.self) { column in | |
columnView(column) | |
.frame( | |
width: columnWidth, | |
height: columnHeight | |
) | |
} | |
} | |
.offset(x: scrollOffset.x) | |
} | |
.disabled(true) | |
ScrollView([.vertical, .horizontal]) { | |
VStack(spacing: 0) { | |
ForEach(rows, id: \.self) { row in | |
HStack(spacing: 0) { | |
ForEach(columns, id: \.self) { column in | |
contentView(column, row) | |
.frame( | |
width: columnWidth, | |
height: rowHeight | |
) | |
} | |
} | |
} | |
} | |
.offset( | |
x: scrollOffset.x, | |
y: scrollOffset.y | |
) | |
} | |
} | |
} | |
} | |
private extension CGPoint { | |
static func + (lhs: Self, rhs: Self) -> Self { | |
CGPoint( | |
x: lhs.x + rhs.x, | |
y: lhs.y + rhs.y | |
) | |
} | |
static func += (lhs: inout Self, rhs: Self) { | |
lhs = lhs + rhs | |
} | |
} | |
private struct ScrollViewOffsetPreferenceKey: PreferenceKey { | |
static var defaultValue = CGPoint.zero | |
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { | |
value += nextValue() | |
} | |
} | |
private struct ObservableScrollView<Content: View>: View { | |
let axis: Axis.Set | |
@Binding var scrollOffset: CGPoint | |
let content: (ScrollViewProxy) -> Content | |
@Namespace var scrollSpace | |
var body: some View { | |
ScrollViewReader { proxy in | |
ScrollView(axis) { | |
content(proxy) | |
.background(GeometryReader { geo in | |
Color.clear | |
.preference( | |
key: ScrollViewOffsetPreferenceKey.self, | |
value: geo.frame(in: .named(scrollSpace)).origin | |
) | |
}) | |
} | |
} | |
.coordinateSpace(name: scrollSpace) | |
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in | |
scrollOffset = value | |
} | |
} | |
} | |
struct SpreadsheetView_Previews: PreviewProvider { | |
static var columns = (0..<10) | |
.map { i in | |
"C \(i)" | |
} | |
static var rows = (0..<10) | |
.map { i in | |
"R \(i)" | |
} | |
static var previews: some View { | |
SpreadsheetView( | |
columns: columns, | |
rows: rows, | |
columnWidth: 100, | |
columnHeight: 50, | |
rowWidth: 100, | |
rowHeight: 50 | |
) { column in | |
Text(column) | |
} rowView: { row in | |
Text(row) | |
} contentView: { column, row in | |
Text("\(column) \(row)") | |
} | |
} | |
} |
When I add 1 row and 1 columns then the heading items are not aligned with the data cell. Also data item should be left top aligned?
@gnchishti Thank you for finding it 🙏 I think so too.
@natpenguin ohhh :) ok I was trying to fix... but not successful yet :)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
What to include in
WindowGroup { SpreadsheetView () }
to be able to run in Xcode project?