xcode - Scrollable Table With Fixed 1st Row and 1st Column (SwiftUI Project) - Stack Overflow

admin2025-05-01  1

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:

  1. In a HStack there are two VStacks
  2. First VStack contains:
    • cell “Group Name” (topLeftCell) - fixed
    • the rowsHeader to be fixed - only vertically movable
    • two cells (rowsFooter) - fixed
  3. Second VStack contains
    • the columnsHeader to be fixed – only horizontally movable
    • the data – can be moved vertically and horizontally
    • the columnsFooter to be fixed – only horizontally movable

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:

  1. How do I have to change the code so that a vertical swipe of the rowsHeader or the table is possible without moving the first row (columnsHeader)?
  2. How do I have to change the code so that a horizontal swipe of the columnsHeader or the table or columnsFooters is possible without moving the first column (rowsHeader)?
  3. If the topElements array is reduced to, for example, only three entries, there is always a gap in the view between the table and the footers. How can I make the footers always appear directly after the table without inserting a gap?
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()
}

转载请注明原文地址:http://www.anycun.com/QandA/1746101889a91693.html