如何在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
相关产品推荐
相关产品推荐

