I am trying to display a table that can be scrolled horizontally and vertically. The first row contains a columnsHeader, which is fixed, so it should not be scrolled vertically, but only horizontally. The first column contains a rowsHeader, which should also be fixed, so it should be scrolled vertically, but not horizontally.
I have tried to accomplish this task using the following basic structure:
I could live with the flaw that the table cannot be moved when the first column (rowsHeader) or the first row (columnsHeader) is being scrolled. However, the column headers (columnsHeader) should be clickable because this is how the columns should be sorted. Unfortunately, this is prevented by the modifier .disabled(true) in func columnsHeaders() -> some View...
Hence my questions:
import SwiftUI
struct GroupView: View {
struct AgeStructureElements {
var debtor: String
var totalSum: Double
var undue: Double
var firstPeriod: Double
var secondPeriod: Double
var thirdPeriod: Double
var fourthPeriod: Double
var unmatched: Double
}
@State private var topElements: [AgeStructureElements] = [
AgeStructureElements(debtor: "Debtor9", totalSum: -1200.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 0.0, fourthPeriod: 0.0, unmatched: -1200.0),
AgeStructureElements(debtor: "Debtor7", totalSum: 400.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 0.0, fourthPeriod: 400.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor6", totalSum: 1200.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 1200.0, fourthPeriod: 0.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor5", totalSum: 800.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 0.0, fourthPeriod: 800.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor31", totalSum: 300.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 300.0, fourthPeriod: 0.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor3", totalSum: 1700.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 900.0, fourthPeriod: 800.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor21", totalSum: 200.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 200.0, thirdPeriod: 0.0, fourthPeriod: 0.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor2", totalSum: 600.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 600.0, thirdPeriod: 0.0, fourthPeriod: 0.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor14", totalSum: 100.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 100.0, fourthPeriod: 0.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor11", totalSum: 1200.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 0.0, fourthPeriod: 1200.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor1", totalSum: 1200.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 0.0, fourthPeriod: 1200.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor2", totalSum: 1200.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 0.0, fourthPeriod: 1200.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor4", totalSum: 1200.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 0.0, fourthPeriod: 1200.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor41", totalSum: 11200.0, undue: 0.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 0.0, fourthPeriod: 11200.0, unmatched: 0.0),
AgeStructureElements(debtor: "Debtor11", totalSum: 100.0, undue: 100.0, firstPeriod: 0.0, secondPeriod: 0.0, thirdPeriod: 0.0, fourthPeriod: 0.0, unmatched: 0.0)
]
@State private var offset = CGPoint.zero
@State private var sumArray: [Double] = []
@State private var percentageArray: [Double] = []
var body: some View {
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
//MARK: - topLeftCell ------------------------------------------
Button(action: {
self.columnHeaderTapped("Group Name")
}) {
HStack {
Text("Group Name")
Image(systemName: "chevron.down")
.scaleEffect(0.5)
}
.minimumScaleFactor(0.5)
.bold()
.frame(width: 150, height: 50, alignment: .center)
.background(.gray.opacity(0.5))
.border(Color.black)
}
// MARK: - rowsHeaders -----------------------------------------
ScrollView([.vertical]) {
VStack(alignment: .leading, spacing: 0) {
ForEach(topElements, id: \.debtor) { ageStructureElement in
Text(ageStructureElement.debtor)
.padding(.leading, 5)
.padding(.trailing, 5)
.frame(width: 150, height: 50, alignment: .leading)
.border(.black)
.offset(y: offset.y)
}
}
}
.disabled(true)
// MARK: - rowsFooters -----------------------------------------
Group {
Text("Total")
Text("in %")
}
.padding(.leading, 5)
.padding(.trailing, 5)
.bold()
.frame(width: 150, height: 30, alignment: .leading)
.border(.black)
.background(.gray.opacity(0.5))
}
VStack(alignment: .leading, spacing: 0) {
// MARK: - columnsHeaders --------------------------------------
columnsHeaders()
// MARK: - table -----------------------------------------------
table()
.coordinateSpace(name: "tableScroll")
// MARK: - columnsFooter 1 -------------------------------------
ScrollView([.horizontal]) {
columnsFooters(values: sumArray, decimals: "2", suffix: "")
.offset(x: offset.x)
.background(.gray.opacity(0.5))
}
.disabled(true)
.onAppear {
let totalSum = topElements.reduce(0) { $0 + $1.totalSum }
let totalUndue = topElements.reduce(0) { $0 + $1.undue }
let totalFirstPeriod = topElements.reduce(0) { $0 + $1.firstPeriod }
let totalSecondPeriod = topElements.reduce(0) { $0 + $1.secondPeriod }
let totalThirdPeriod = topElements.reduce(0) { $0 + $1.thirdPeriod }
let totalFourthPeriod = topElements.reduce(0) { $0 + $1.fourthPeriod }
let totalUnmatched = topElements.reduce(0) { $0 + $1.unmatched }
sumArray = [totalSum, totalUndue, totalFirstPeriod, totalSecondPeriod, totalThirdPeriod, totalFourthPeriod, totalUnmatched]
for sumArrayElement in sumArray {
percentageArray.append((sumArrayElement / totalSum * 1000).rounded() / 10)
}
}
// MARK: - columnsFooter 2 -------------------------------------
ScrollView([.horizontal]) {
columnsFooters(values: percentageArray, decimals: "1", suffix: "%")
.offset(x: offset.x)
.background(.gray.opacity(0.5))
}
.disabled(true)
}
}
.padding(.top, 10)
.padding(.bottom, 10)
.offset(y: 0)
}
func columnHeaderTapped(_ columnHeaderName: String) {
print("\(columnHeaderName) clicked")
topElements.sort { $0.debtor > $1.debtor }
}
func columnsHeaders() -> some View {
ScrollView([.horizontal]) {
HStack(alignment: .top, spacing: 0) {
let periodsArray = ["B", "C", "D", "E", "F", "G", "H"]
ForEach(periodsArray, id: \.self) { columnHeaderName in
Button(action: {
// Sorting logic (commented out)
}) {
HStack {
Text(columnHeaderName)
Image(systemName: "chevron.down")
.scaleEffect(0.5)
}
.minimumScaleFactor(0.5)
.bold()
.frame(width: 100, height: 50, alignment: .center)
.border(Color.black)
.background(Color.gray.opacity(0.5))
}
}
}
.offset(x: offset.x)
}
.disabled(true)
}
func table() -> some View {
ScrollView([.vertical]) {
ScrollView([.horizontal]) {
VStack(alignment: .leading, spacing: 0) {
ForEach(topElements.indices, id: \.self) { debtor in
HStack(alignment: .top, spacing: 0) {
Group {
Text(String(format: "%.2f", topElements[debtor].totalSum))
Text(String(format: "%.2f", topElements[debtor].undue))
Text(String(format: "%.2f", topElements[debtor].firstPeriod))
Text(String(format: "%.2f", topElements[debtor].secondPeriod))
Text(String(format: "%.2f", topElements[debtor].thirdPeriod))
Text(String(format: "%.2f", topElements[debtor].fourthPeriod))
Text(String(format: "%.2f", topElements[debtor].unmatched))
}
.padding(.leading, 5)
.padding(.trailing, 5)
.lineLimit(1)
.minimumScaleFactor(0.5)
.frame(width: 100, height: 50, alignment: .trailing)
.border(Color.black)
}
}
}
.background(GeometryReader { geo in
Color.clear
.preference(key: TableOffsetKeys.self, value: geo.frame(in: .named("tableScroll")).origin)
})
.onPreferenceChange(TableOffsetKeys.self) { value in
offset = value
}
}
}
}
func columnsFooters(values: [Double], decimals: String, suffix: String) -> some View {
HStack(alignment: .top, spacing: 0) {
ForEach(values, id: \.self) { value in
Text(String(format: "%." + decimals + "f", value) + suffix)
.padding(.leading, 5)
.padding(.trailing, 5)
.minimumScaleFactor(0.5)
.bold()
.frame(width: 100, height: 30, alignment: .trailing)
.border(.black)
}
}
}
}
// MARK: - OffsetKeys -----------------------------------------------------------
struct TableOffsetKeys: 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
print("TableOffsetKeys x: \(value.x)")
print("TableOffsetKeys y: \(value.y)")
}
}
#Preview {
GroupView()
}