如何在SwiftUI Charts中实现X轴时间范围平滑切换与刻度适配?
SwiftUI Charts 时间范围平滑切换实现方案
核心需求梳理
- 按时间粒度(周/月)聚合数据:周视图展示每周求和值,月视图展示每月求和值
- 实现类似iOS健康App的X轴时间范围平滑缩放(3个月↔9个月视图切换)
- 动态适配X轴刻度:3个月视图显示每周第一天,9个月视图仅显示月份名称
代码修改实现
1. 拆分状态与新增数据聚合逻辑
先拆分原有的type变量,分别管理数据类型和时间范围,同时实现按周/月聚合数据的方法:
import SwiftUI import Charts // 时间范围枚举 enum TimeRange: Int { case threeMonths = 0 case nineMonths = 1 var dayCount: Int { switch self { case .threeMonths: return 90 case .nineMonths: return 270 } } } // 数据类型枚举 enum DataType: Int { case expense = 0 case fuel = 1 case mileage = 2 case carWash = 3 var tintColor: Color { switch self { case .expense: return .orange case .fuel: return .green case .mileage: return .blue case .carWash: return .purple } } func getValue(from entry: Entry) -> Double { switch self { case .expense: return entry.value ?? 0 case .fuel: return entry.quantity ?? 0 case .mileage: return entry.trip ?? 0 case .carWash: return entry.quantity ?? 0 } } } struct ChartsV: View { @State private var isSheetPresented = false @State var contentHasScrolled = false @ObservedObject var dataStore: DataStoreVM @State private var selectedDataType: DataType = .expense @State private var selectedTimeRange: TimeRange = .threeMonths init(dataStore: DataStoreVM) { self.dataStore = dataStore let navBarAppearance = UINavigationBarAppearance() UINavigationBar.appearance().standardAppearance = navBarAppearance } // 按时间粒度聚合数据 private func aggregatedData() -> [(date: Date, total: Double)] { let calendar = Calendar.current // 过滤时间范围内的数据 let cutoffDate = calendar.date(byAdding: .day, value: -selectedTimeRange.dayCount, to: Date())! let validEntries = dataStore.entries.filter { $0.date >= cutoffDate } // 按周/月分组 let groupingUnit: Calendar.Component = selectedTimeRange == .threeMonths ? .weekOfYear : .month let groupedEntries = Dictionary(grouping: validEntries) { entry in calendar.startOfDay(for: calendar.date(from: calendar.dateComponents([groupingUnit, .year], from: entry.date))!) } // 分组求和 return groupedEntries.map { (date, entries) in let sum = entries.reduce(0) { $0 + selectedDataType.getValue(from: $1) } return (date: date, total: sum) }.sorted { $0.date < $1.date } } // 计算Y轴最大值 private func yAxisMax() -> Double { let maxVal = aggregatedData().map { $0.total }.max() ?? 1 return max(maxVal, 1) * 1.1 } // 计算Y轴最小值 private func yAxisMin() -> Double { let values = aggregatedData().map { $0.total } let maxVal = values.max() ?? 1 if values.allSatisfy({ $0 == 0 }) { return -0.1 } else { return max(0, maxVal * 0.95) * -0.1 } }
2. 实现平滑切换的图表配置
修改Chart组件,动态适配X轴范围、刻度样式,并添加平滑动画:
var body: some View { NavigationView { ZStack { LinearGradient( gradient: Gradient(stops: [ .init(color: Color(uiColor: .systemBackground), location: 0.1), .init(color: selectedDataType.tintColor, location: 0.7), .init(color: Color(uiColor: .systemBackground), location: 1.2) ]), startPoint: .top, endPoint: .bottom ) .animation(.easeInOut(duration: 0.6), value: selectedDataType) .mask( LinearGradient( gradient: Gradient(stops: [ .init(color: Color.black.opacity(0.3), location: -0.5), .init(color: Color.black.opacity(0.5), location: 0.5), .init(color: Color.black.opacity(0.3), location: 1.5) ]), startPoint: .leading, endPoint: .trailing ) ) .edgesIgnoringSafeArea(.all) VStack { Text("图表") .foregroundColor(.primary) .opacity(0.5) .fontWeight(.bold) .padding() Chart(aggregatedData(), id: \.date) { item in LineMark( x: .value("日期", item.date), y: .value("总计", item.total) ) .foregroundStyle(selectedDataType.tintColor) .lineStyle(StrokeStyle(lineWidth: 8, lineCap: .round)) .interpolationMethod(.monotone) } .chartScrollableAxes(.horizontal) // 动态设置X轴时间范围 .chartXScale(domain: { let calendar = Calendar.current let endDate = Date() let startDate = calendar.date(byAdding: .day, value: -selectedTimeRange.dayCount, to: endDate)! return startDate...endDate }()) .chartYScale(domain: yAxisMin()...yAxisMax()) .frame(height: 200) .chartXAxis { AxisMarks(position: .bottom, values: .stride(by: selectedTimeRange == .threeMonths ? .weekOfYear : .month)) { value in if let date = value.as(Date.self) { // 动态调整日期显示格式 let dateFormat: Date.FormatStyle = selectedTimeRange == .threeMonths ? .dateTime.weekday(.abbreviated).day() : .dateTime.month(.abbreviated) AxisValueLabel(format: dateFormat) } AxisTick() AxisGridLine() } } .chartYAxis {} // 添加平滑切换动画 .animation(.easeInOut(duration: 0.6), value: selectedTimeRange) .animation(.timingCurve(0.2, 0.5, 0, 1, duration: 0.8), value: selectedDataType) // 数据类型选择器 Picker("数据类型", selection: $selectedDataType) { Image(systemName: "dollarsign").tag(DataType.expense) Image(systemName: "fuelpump.fill").tag(DataType.fuel) Image(systemName: "road.lanes").tag(DataType.mileage) Image(systemName: "bubbles.and.sparkles.fill").tag(DataType.carWash) } .pickerStyle(.segmented) .padding() // 时间范围选择器 Picker("时间范围", selection: $selectedTimeRange) { Text("3个月").tag(TimeRange.threeMonths) Text("9个月").tag(TimeRange.nineMonths) } .pickerStyle(.segmented) .padding(.horizontal, 80) } .background(.thinMaterial) .cornerRadius(20) .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 10) .padding([.leading, .bottom, .trailing]) NavigationBarV(backgroundColor: Color(UIColor.bgGradient1), title: "图表", back: "汇总", contentHasScrolled: $contentHasScrolled) VStack { Spacer() HStack { Spacer() BottomButtonV(bottomButton: BottomButtonM(icon: "plus", color: Color.primary)) { isSheetPresented.toggle() } } .padding() } } .sheet(isPresented: $isSheetPresented) { NewEntryV(dataStore: dataStore) { isSheetPresented = false } } } } }
3. 关键优化说明
- 数据聚合:通过字典分组+求和,确保不同时间粒度下展示的是汇总数据,而非原始单条记录
- 动态X轴:使用
chartXScale明确指定时间范围,配合动画实现平滑缩放效果 - 刻度适配:根据时间范围自动切换刻度步长和日期格式,3个月视图显示周+日,9个月视图仅显示月份
- 状态拆分:将原有的单一
type变量拆分为数据类型和时间范围两个独立状态,避免逻辑混淆
内容的提问来源于stack exchange,提问作者Dawid
相关产品推荐
相关产品推荐

