You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在SwiftUI中实现多列可滚动、可排序的表格视图?

在SwiftUI中实现多列可滚动、支持排序的表格视图

需求

  • 同时支持垂直与水平滚动
  • 可通过任意列进行排序
  • 点击表格行可跳转至详情视图
  • 参考股票交易应用Thinkorswim的表格样式(忽略火花图列)

问题说明

使用SwiftUI原生Table组件实现时,在iOS平台的紧凑水平尺寸类环境(如iPhone或iPad的Slide Over模式)下,Table会自动隐藏表头及第一列之外的所有列,仅显示单列。虽然可以针对该场景做单列兼容,但希望实现多列且支持滚动的方案。以下是初始尝试代码:

struct TickerDetails: Identifiable {
    let id = UUID()
    let ticker: String
    let price: Double
    let percChange: Double
    let volume: Int
}

struct TableViewExample: View {
    @State var tickers = [TickerDetails(ticker: "AAPL", price: 100.0, percChange: 1.2, volume: 4_500_000),
                          TickerDetails(ticker: "AMZN", price: 180.0, percChange: 2.5, volume: 6_000_000),
                          TickerDetails(ticker: "NVDA", price: 130.0, percChange: 5.2, volume: 7_500_000)]
    var body: some View {
        Table(tickers) {
            TableColumn("Ticker", value: \.ticker)
            TableColumn("Mark") { ticker in
                Text(String(ticker.price))
            }
            TableColumn("Mark %") { ticker in
                Text(String(ticker.percChange))
            }
            TableColumn("Volume") { ticker in
                Text(String(ticker.volume))
            }
        }
    }
}

解决方案:自定义多列滚动表格

由于原生Table在紧凑尺寸类的限制,我们可以通过嵌套ScrollView、HStack和VStack来实现自定义表格,同时处理排序逻辑和行点击跳转:

完整实现代码

import SwiftUI

// 数据模型
struct TickerDetails: Identifiable {
    let id = UUID()
    let ticker: String
    let price: Double
    let percChange: Double
    let volume: Int
    
    // 格式化显示的值
    var formattedPrice: String { String(format: "%.2f", price) }
    var formattedPercChange: String { String(format: "%.2f%%", percChange) }
    var formattedVolume: String {
        NumberFormatter.decimal.string(from: NSNumber(value: volume)) ?? "\(volume)"
    }
}

// 排序枚举
enum SortKey: String, CaseIterable {
    case ticker, price, percChange, volume
    
    var displayName: String {
        switch self {
        case .ticker: return "Ticker"
        case .price: return "Mark"
        case .percChange: return "Mark %"
        case .volume: return "Volume"
        }
    }
}

struct CustomStockTableView: View {
    @State private var tickers: [TickerDetails] = [
        TickerDetails(ticker: "AAPL", price: 100.0, percChange: 1.2, volume: 4_500_000),
        TickerDetails(ticker: "AMZN", price: 180.0, percChange: 2.5, volume: 6_000_000),
        TickerDetails(ticker: "NVDA", price: 130.0, percChange: 5.2, volume: 7_500_000),
        TickerDetails(ticker: "MSFT", price: 380.50, percChange: -0.8, volume: 3_200_000),
        TickerDetails(ticker: "GOOGL", price: 142.30, percChange: 3.1, volume: 5_800_000)
    ]
    @State private var sortKey: SortKey = .ticker
    @State private var isAscending = true
    
    // 排序处理
    private var sortedTickers: [TickerDetails] {
        tickers.sorted {
            switch sortKey {
            case .ticker:
                return isAscending ? $0.ticker < $1.ticker : $0.ticker > $1.ticker
            case .price:
                return isAscending ? $0.price < $1.price : $0.price > $1.price
            case .percChange:
                return isAscending ? $0.percChange < $1.percChange : $0.percChange > $1.percChange
            case .volume:
                return isAscending ? $0.volume < $1.volume : $0.volume > $1.volume
            }
        }
    }
    
    var body: some View {
        NavigationStack {
            ScrollView([.vertical, .horizontal]) {
                VStack(spacing: 0) {
                    // 表头
                    HStack(spacing: 0) {
                        ForEach(SortKey.allCases, id: \.self) { key in
                            headerCell(for: key)
                                .frame(width: 120) // 固定列宽,可根据需求调整
                        }
                    }
                    .background(Color(.systemGray6))
                    
                    // 数据行
                    ForEach(sortedTickers) { ticker in
                        NavigationLink(destination: TickerDetailView(ticker: ticker)) {
                            HStack(spacing: 0) {
                                cellContent(for: ticker, key: .ticker)
                                    .frame(width: 120)
                                cellContent(for: ticker, key: .price)
                                    .frame(width: 120)
                                cellContent(for: ticker, key: .percChange)
                                    .frame(width: 120)
                                    .foregroundColor(ticker.percChange >= 0 ? .green : .red)
                                cellContent(for: ticker, key: .volume)
                                    .frame(width: 120)
                            }
                            .background(Color(.systemBackground))
                            .overlay(
                                Rectangle()
                                    .frame(height: 1)
                                    .foregroundColor(Color(.systemGray5)),
                                alignment: .bottom
                            )
                        }
                        .buttonStyle(.plain)
                    }
                }
            }
            .navigationTitle("Stock Table")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
    
    // 表头单元格
    private func headerCell(for key: SortKey) -> some View {
        Button(action: {
            if sortKey == key {
                isAscending.toggle()
            } else {
                sortKey = key
                isAscending = true
            }
        }) {
            HStack {
                Text(key.displayName)
                    .font(.headline)
                Image(systemName: sortKey == key ? (isAscending ? "chevron.up" : "chevron.down") : "")
                    .font(.caption)
            }
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .foregroundColor(.primary)
    }
    
    // 数据单元格内容
    private func cellContent(for ticker: TickerDetails, key: SortKey) -> some View {
        Text(
            switch key {
            case .ticker: ticker.ticker
            case .price: ticker.formattedPrice
            case .percChange: ticker.formattedPercChange
            case .volume: ticker.formattedVolume
            }
        )
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

// 详情视图示例
struct TickerDetailView: View {
    let ticker: TickerDetails
    
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text(ticker.ticker)
                .font(.largeTitle)
                .fontWeight(.bold)
            Text("Price: \(ticker.formattedPrice)")
            Text("Change: \(ticker.formattedPercChange)")
            Text("Volume: \(ticker.formattedVolume)")
            Spacer()
        }
        .padding()
        .navigationTitle("Details")
    }
}

// 格式化工具扩展
extension NumberFormatter {
    static let decimal: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.groupingSeparator = ","
        formatter.maximumFractionDigits = 0
        return formatter
    }()
}

struct CustomStockTableView_Previews: PreviewProvider {
    static var previews: some View {
        CustomStockTableView()
    }
}

实现要点

  • 滚动支持:通过ScrollView([.vertical, .horizontal])实现同时垂直和水平滚动
  • 排序功能:通过SortKey枚举管理排序字段,点击表头切换排序规则,显示排序指示器
  • 行点击跳转:使用NavigationLink包裹数据行,点击后跳转到详情视图
  • 样式控制:固定列宽(可按需调整),表头使用灰色背景区分,行底部添加分隔线,涨跌颜色区分
  • 数据格式化:对价格、涨跌幅、成交量做格式化处理,提升可读性

内容的提问来源于stack exchange,提问作者Pranav

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 03:53:11