从零开始学iOS开发(第四十五篇):SwiftUI 数据可视化进阶 —— 构建交互式图表与仪表盘

欢迎来到本系列教程的第四十五篇。在前四十四篇文章中,你已经学习了从Swift基础到App Store提交的全方位iOS开发技能。现在,你能够构建出完整的、可上架的iOS应用了。在本篇中,我们将深入探索SwiftUI数据可视化的高级技巧,帮助你构建能够吸引用户的交互式图表和仪表盘。

在这一篇中,你将学到:

  1. 高级图表技巧

    • 组合图表

    • 动态数据更新

    • 图表主题与样式

  2. 交互式图表

    • 图表情景

    • 手势与细节展示

    • 缩放与平移

  3. 实时数据流

    • 动态更新图表

    • 动画过渡

  4. 自定义图表组件

    • 创建可复用图表组件

    • 图表配置协议

  5. 实战项目:构建完整的财务仪表盘


一、高级图表技巧

1.1 组合图表

swift

复制代码
import SwiftUI
import Charts

// MARK: - 组合图表(柱状图+折线图)
struct CombinedChartView: View {
    let monthlyData: [MonthlyData]
    
    struct MonthlyData: Identifiable {
        let id = UUID()
        let month: String
        let sales: Double
        let target: Double
        let profit: Double
    }
    
    var body: some View {
        Chart(monthlyData) { item in
            // 柱状图(销售额)
            BarMark(
                x: .value("月份", item.month),
                y: .value("销售额", item.sales)
            )
            .foregroundStyle(.blue.gradient)
            .position(by: .value("类型", "销售额"))
            
            // 柱状图(目标)
            BarMark(
                x: .value("月份", item.month),
                y: .value("目标", item.target)
            )
            .foregroundStyle(.orange.gradient)
            .position(by: .value("类型", "目标"))
            
            // 折线图(利润)
            LineMark(
                x: .value("月份", item.month),
                y: .value("利润", item.profit)
            )
            .foregroundStyle(.green)
            .lineStyle(StrokeStyle(lineWidth: 2))
            .symbol(Circle().strokeBorder(lineWidth: 2))
        }
        .frame(height: 300)
        .padding()
        .chartLegend(position: .top, alignment: .trailing)
        .chartYAxisLabel("金额(万元)")
    }
}

1.2 动态数据更新

swift

复制代码
// MARK: - 动态更新图表
struct DynamicChartView: View {
    @State private var dataPoints: [DataPoint] = generateInitialData()
    @State private var isAnimating = false
    
    struct DataPoint: Identifiable {
        let id = UUID()
        let x: Int
        let y: Double
    }
    
    static func generateInitialData() -> [DataPoint] {
        (0..<20).map { i in
            DataPoint(x: i, y: Double.random(in: 10...100))
        }
    }
    
    var body: some View {
        VStack {
            Chart(dataPoints) { point in
                LineMark(
                    x: .value("X", point.x),
                    y: .value("Y", point.y)
                )
                .foregroundStyle(.blue)
                .interpolationMethod(.catmullRom)
            }
            .frame(height: 250)
            .padding()
            .chartYScale(domain: 0...120)
            
            Button("随机新增数据") {
                withAnimation(.spring()) {
                    let newX = (dataPoints.last?.x ?? -1) + 1
                    let newPoint = DataPoint(
                        x: newX,
                        y: Double.random(in: 10...100)
                    )
                    dataPoints.append(newPoint)
                    
                    if dataPoints.count > 30 {
                        dataPoints.removeFirst()
                    }
                }
            }
            .buttonStyle(.borderedProminent)
            
            Button("重置数据") {
                withAnimation(.spring()) {
                    dataPoints = Self.generateInitialData()
                }
            }
            .buttonStyle(.bordered)
        }
    }
}

1.3 图表主题与样式

swift

复制代码
// MARK: - 主题样式扩展
extension ChartContent {
    func applyTheme(_ theme: ChartTheme) -> some ChartContent {
        self
            .foregroundStyle(theme.colors)
            .chartForegroundStyleScale(theme.colorMapping)
    }
}

struct ChartTheme {
    let name: String
    let colors: AnyGradient
    let colorMapping: [String: Color]
    
    static let sunset = ChartTheme(
        name: "日落",
        colors: LinearGradient(colors: [.orange, .red], startPoint: .top, endPoint: .bottom).eraseToAnyGradient(),
        colorMapping: ["销售额": .orange, "利润": .red]
    )
    
    static let ocean = ChartTheme(
        name: "海洋",
        colors: LinearGradient(colors: [.blue, .teal], startPoint: .top, endPoint: .bottom).eraseToAnyGradient(),
        colorMapping: ["销售额": .blue, "利润": .teal]
    )
    
    static let forest = ChartTheme(
        name: "森林",
        colors: LinearGradient(colors: [.green, .mint], startPoint: .top, endPoint: .bottom).eraseToAnyGradient(),
        colorMapping: ["销售额": .green, "利润": .mint]
    )
    
    static let allThemes: [ChartTheme] = [.sunset, .ocean, .forest]
}

extension Gradient {
    func eraseToAnyGradient() -> AnyGradient {
        AnyGradient(self)
    }
}

二、交互式图表

2.1 图表情景

swift

复制代码
// MARK: - 带选择功能的图表
struct SelectableChartView: View {
    let data: [SalesData]
    @State private var selectedMonth: String?
    @State private var selectedValue: Double?
    
    struct SalesData: Identifiable {
        let id = UUID()
        let month: String
        let sales: Double
    }
    
    var body: some View {
        VStack(spacing: 20) {
            Chart(data) { item in
                BarMark(
                    x: .value("月份", item.month),
                    y: .value("销售额", item.sales)
                )
                .foregroundStyle(selectedMonth == item.month ? Color.orange.gradient : Color.blue.gradient)
                .opacity(selectedMonth == nil || selectedMonth == item.month ? 1 : 0.5)
            }
            .frame(height: 250)
            .padding()
            .chartXSelection(value: $selectedMonth)
            .onChange(of: selectedMonth) { _, newMonth in
                if let month = newMonth,
                   let item = data.first(where: { $0.month == month }) {
                    selectedValue = item.sales
                } else {
                    selectedValue = nil
                }
            }
            
            if let month = selectedMonth, let value = selectedValue {
                VStack(spacing: 8) {
                    Text(month)
                        .font(.headline)
                    Text("销售额: \(Int(value))万元")
                        .font(.title2)
                        .bold()
                        .foregroundColor(.blue)
                }
                .padding()
                .frame(maxWidth: .infinity)
                .background(Color(.systemGray6))
                .cornerRadius(12)
                .padding(.horizontal)
                .transition(.opacity)
            }
        }
        .animation(.easeInOut, value: selectedMonth)
    }
}

2.2 手势缩放与平移

swift

复制代码
// MARK: - 可缩放图表
struct ZoomableChartView: View {
    let data: [DailyData]
    @State private var visibleRange: ClosedRange<Int> = 0...29
    @State private var scale: CGFloat = 1.0
    @State private var offset: CGFloat = 0
    
    struct DailyData: Identifiable {
        let id = UUID()
        let day: Int
        let value: Double
    }
    
    var visibleData: [DailyData] {
        Array(data[visibleRange])
    }
    
    var body: some View {
        VStack {
            Chart(visibleData) { item in
                LineMark(
                    x: .value("日期", item.day),
                    y: .value("数值", item.value)
                )
                .foregroundStyle(.blue)
                .interpolationMethod(.catmullRom)
            }
            .frame(height: 250)
            .padding()
            .chartXScale(domain: visibleRange.lowerBound...visibleRange.upperBound)
            .chartYScale(domain: 0...100)
            
            // 范围选择滑块
            VStack(alignment: .leading, spacing: 8) {
                Text("显示范围: \(visibleRange.lowerBound + 1) - \(visibleRange.upperBound + 1)")
                    .font(.caption)
                    .foregroundColor(.secondary)
                
                HStack {
                    Text("1")
                        .font(.caption2)
                    Slider(
                        value: Binding(
                            get: { Double(visibleRange.lowerBound) },
                            set: { visibleRange = Int($0)...visibleRange.upperBound }
                        ),
                        in: 0...Double(data.count - (visibleRange.upperBound - visibleRange.lowerBound)),
                        step: 1
                    )
                    Text("\(data.count)")
                        .font(.caption2)
                }
                
                HStack {
                    Text("宽度: \(visibleRange.upperBound - visibleRange.lowerBound + 1)天")
                        .font(.caption)
                        .foregroundColor(.secondary)
                    Spacer()
                    Button("重置") {
                        withAnimation {
                            visibleRange = 0...29
                        }
                    }
                    .font(.caption)
                }
            }
            .padding(.horizontal)
        }
    }
}

2.3 自定义标注

swift

复制代码
// MARK: - 带标注的图表
struct AnnotatedChartView: View {
    let data: [QuarterData]
    
    struct QuarterData: Identifiable {
        let id = UUID()
        let quarter: String
        let revenue: Double
        let expenses: Double
    }
    
    var body: some View {
        Chart {
            ForEach(data) { item in
                BarMark(
                    x: .value("季度", item.quarter),
                    y: .value("收入", item.revenue)
                )
                .foregroundStyle(.green.gradient)
                .annotation(position: .top) {
                    if item.revenue > 100 {
                        Text("\(Int(item.revenue))")
                            .font(.caption)
                            .foregroundColor(.green)
                    }
                }
                
                BarMark(
                    x: .value("季度", item.quarter),
                    y: .value("支出", item.expenses)
                )
                .foregroundStyle(.red.gradient)
                .annotation(position: .top) {
                    if item.expenses > 80 {
                        Text("⚠️")
                            .font(.caption)
                    }
                }
            }
            
            // 目标线
            RuleMark(
                y: .value("目标", 120)
            )
            .foregroundStyle(.orange)
            .lineStyle(StrokeStyle(lineWidth: 1, dash: [5]))
            .annotation(position: .trailing) {
                Text("目标")
                    .font(.caption2)
                    .foregroundColor(.orange)
            }
        }
        .frame(height: 300)
        .padding()
        .chartYScale(domain: 0...160)
        .chartYAxisLabel("金额(万元)")
    }
}

三、实时数据流

3.1 动态更新图表

swift

复制代码
// MARK: - 实时数据流图表
class DataStreamManager: ObservableObject {
    @Published var dataPoints: [DataPoint] = []
    private var timer: Timer?
    
    struct DataPoint: Identifiable {
        let id = UUID()
        let timestamp: Date
        let value: Double
    }
    
    func startStreaming() {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            let newPoint = DataPoint(
                timestamp: Date(),
                value: Double.random(in: 20...80)
            )
            
            DispatchQueue.main.async {
                self.dataPoints.append(newPoint)
                if self.dataPoints.count > 60 {
                    self.dataPoints.removeFirst()
                }
            }
        }
    }
    
    func stopStreaming() {
        timer?.invalidate()
        timer = nil
    }
}

struct RealTimeChartView: View {
    @StateObject private var streamManager = DataStreamManager()
    @State private var isStreaming = false
    @State private var showAverage = false
    
    var averageValue: Double {
        guard !streamManager.dataPoints.isEmpty else { return 0 }
        return streamManager.dataPoints.reduce(0) { $0 + $1.value } / Double(streamManager.dataPoints.count)
    }
    
    var body: some View {
        VStack {
            Chart(streamManager.dataPoints) { point in
                LineMark(
                    x: .value("时间", point.timestamp),
                    y: .value("数值", point.value)
                )
                .foregroundStyle(.blue)
                .interpolationMethod(.catmullRom)
                
                if showAverage {
                    RuleMark(
                        y: .value("平均值", averageValue)
                    )
                    .foregroundStyle(.orange)
                    .lineStyle(StrokeStyle(lineWidth: 1, dash: [5]))
                    .annotation(position: .trailing) {
                        Text("平均: \(Int(averageValue))")
                            .font(.caption2)
                            .foregroundColor(.orange)
                    }
                }
            }
            .frame(height: 250)
            .padding()
            .chartYScale(domain: 0...100)
            .chartXAxis {
                AxisMarks(values: .stride(by: .second, count: 10)) { _ in
                    AxisValueLabel(format: .dateTime.second())
                }
            }
            
            HStack(spacing: 20) {
                Button(isStreaming ? "停止" : "开始") {
                    if isStreaming {
                        streamManager.stopStreaming()
                    } else {
                        streamManager.startStreaming()
                    }
                    isStreaming.toggle()
                }
                .buttonStyle(.borderedProminent)
                
                Button("清除") {
                    streamManager.dataPoints.removeAll()
                }
                .buttonStyle(.bordered)
                
                Toggle("显示平均值", isOn: $showAverage)
                    .frame(width: 120)
            }
            .padding()
            
            Text("当前值: \(Int(streamManager.dataPoints.last?.value ?? 0))")
                .font(.title2)
                .bold()
        }
        .onDisappear {
            streamManager.stopStreaming()
        }
    }
}

3.2 动画过渡

swift

复制代码
// MARK: - 图表动画过渡
struct AnimatedTransitionChart: View {
    @State private var dataSetIndex = 0
    @State private var dataSets: [[SalesData]] = [
        SalesData.sampleData1,
        SalesData.sampleData2,
        SalesData.sampleData3
    ]
    
    struct SalesData: Identifiable {
        let id = UUID()
        let category: String
        let value: Double
        let previousValue: Double?
        
        static let sampleData1 = [
            SalesData(category: "电子产品", value: 120, previousValue: 100),
            SalesData(category: "服装", value: 80, previousValue: 90),
            SalesData(category: "食品", value: 95, previousValue: 85),
            SalesData(category: "美妆", value: 60, previousValue: 55)
        ]
        
        static let sampleData2 = [
            SalesData(category: "电子产品", value: 145, previousValue: 120),
            SalesData(category: "服装", value: 65, previousValue: 80),
            SalesData(category: "食品", value: 110, previousValue: 95),
            SalesData(category: "美妆", value: 75, previousValue: 60)
        ]
        
        static let sampleData3 = [
            SalesData(category: "电子产品", value: 98, previousValue: 145),
            SalesData(category: "服装", value: 95, previousValue: 65),
            SalesData(category: "食品", value: 85, previousValue: 110),
            SalesData(category: "美妆", value: 90, previousValue: 75)
        ]
    }
    
    var currentData: [SalesData] {
        dataSets[dataSetIndex]
    }
    
    var body: some View {
        VStack {
            Chart(currentData) { item in
                BarMark(
                    x: .value("类别", item.category),
                    y: .value("销售额", item.value)
                )
                .foregroundStyle(.blue.gradient)
                .opacity(0.8)
                .annotation(position: .top) {
                    if let previous = item.previousValue {
                        let change = item.value - previous
                        let percentage = (change / previous) * 100
                        
                        HStack(spacing: 2) {
                            Image(systemName: change >= 0 ? "arrow.up" : "arrow.down")
                                .font(.caption2)
                            Text(String(format: "%.1f%%", abs(percentage)))
                                .font(.caption2)
                        }
                        .foregroundColor(change >= 0 ? .green : .red)
                    }
                }
            }
            .frame(height: 300)
            .padding()
            .chartYScale(domain: 0...200)
            .animation(.spring(), value: dataSetIndex)
            
            HStack(spacing: 20) {
                ForEach(0..<dataSets.count, id: \.self) { index in
                    Button("数据集 \(index + 1)") {
                        withAnimation(.spring()) {
                            dataSetIndex = index
                        }
                    }
                    .buttonStyle(.bordered)
                    .tint(dataSetIndex == index ? .blue : .gray)
                }
            }
            .padding()
        }
    }
}

四、自定义图表组件

4.1 可复用图表组件

swift

复制代码
// MARK: - 通用柱状图组件
struct BarChartView<Data: RandomAccessCollection>: View where Data.Element: Identifiable, Data.Element: BarChartDataProtocol {
    let data: Data
    var title: String = ""
    var subtitle: String = ""
    var barColor: Color = .blue
    @Binding var selectedItem: Data.Element.ID?
    
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            if !title.isEmpty {
                Text(title)
                    .font(.headline)
                if !subtitle.isEmpty {
                    Text(subtitle)
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
            }
            
            Chart(data) { item in
                BarMark(
                    x: .value("类别", item.categoryName),
                    y: .value("数值", item.value)
                )
                .foregroundStyle(barColor.gradient)
                .opacity(selectedItem == nil || selectedItem == item.id ? 1 : 0.5)
            }
            .frame(height: 250)
            .chartXSelection(value: $selectedItem)
        }
        .padding()
        .background(Color(.systemGray6))
        .cornerRadius(16)
        .padding(.horizontal)
    }
}

protocol BarChartDataProtocol {
    var id: UUID { get }
    var categoryName: String { get }
    var value: Double { get }
}

extension SalesData: BarChartDataProtocol {
    var categoryName: String { category }
}

// MARK: - 通用折线图组件
struct LineChartView<Data: RandomAccessCollection>: View where Data.Element: Identifiable, Data.Element: LineChartDataProtocol {
    let data: Data
    var title: String = ""
    var lineColor: Color = .blue
    var showPoints: Bool = true
    
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            if !title.isEmpty {
                Text(title)
                    .font(.headline)
                    .padding(.leading)
            }
            
            Chart(data) { item in
                LineMark(
                    x: .value("X", item.xValue),
                    y: .value("Y", item.yValue)
                )
                .foregroundStyle(lineColor)
                .lineStyle(StrokeStyle(lineWidth: 2))
                
                if showPoints {
                    PointMark(
                        x: .value("X", item.xValue),
                        y: .value("Y", item.yValue)
                    )
                    .foregroundStyle(lineColor)
                }
            }
            .frame(height: 200)
            .padding()
            .background(Color(.systemGray6))
            .cornerRadius(16)
            .padding(.horizontal)
        }
    }
}

protocol LineChartDataProtocol {
    var id: UUID { get }
    var xValue: Double { get }
    var yValue: Double { get }
}

4.2 图表配置协议

swift

复制代码
// MARK: - 图表配置协议
protocol ChartConfiguration {
    var backgroundColor: Color { get }
    var axisColor: Color { get }
    var gridColor: Color { get }
    var font: Font { get }
    var animation: Animation { get }
}

struct LightChartConfig: ChartConfiguration {
    let backgroundColor: Color = .white
    let axisColor: Color = .black.opacity(0.7)
    let gridColor: Color = .gray.opacity(0.2)
    let font: Font = .caption
    let animation: Animation = .easeInOut(duration: 0.3)
}

struct DarkChartConfig: ChartConfiguration {
    let backgroundColor: Color = .black
    let axisColor: Color = .white.opacity(0.8)
    let gridColor: Color = .white.opacity(0.1)
    let font: Font = .caption
    let animation: Animation = .spring(response: 0.5)
}

// MARK: - 环境值传递
struct ChartConfigurationKey: EnvironmentKey {
    static let defaultValue: ChartConfiguration = LightChartConfig()
}

extension EnvironmentValues {
    var chartConfig: ChartConfiguration {
        get { self[ChartConfigurationKey.self] }
        set { self[ChartConfigurationKey.self] = newValue }
    }
}

// MARK: - 使用配置的图表
struct ConfigurableChartView: View {
    @Environment(\.chartConfig) var config
    let data: [SalesData]
    
    var body: some View {
        Chart(data) { item in
            BarMark(
                x: .value("类别", item.category),
                y: .value("销售额", item.value)
            )
            .foregroundStyle(.blue.gradient)
        }
        .frame(height: 250)
        .padding()
        .background(config.backgroundColor)
        .cornerRadius(16)
        .chartXAxis {
            AxisMarks { _ in
                AxisValueLabel()
                    .font(config.font)
                    .foregroundStyle(config.axisColor)
                AxisGridLine()
                    .foregroundStyle(config.gridColor)
            }
        }
        .chartYAxis {
            AxisMarks { _ in
                AxisValueLabel()
                    .font(config.font)
                    .foregroundStyle(config.axisColor)
                AxisGridLine()
                    .foregroundStyle(config.gridColor)
            }
        }
    }
}

五、实战:财务仪表盘

swift

复制代码
import SwiftUI
import Charts

// MARK: - 数据模型
struct FinancialData: Identifiable {
    let id = UUID()
    let date: Date
    let income: Double
    let expense: Double
    let category: String
    
    var dateString: String {
        let formatter = DateFormatter()
        formatter.dateFormat = "MM/dd"
        return formatter.string(from: date)
    }
}

// MARK: - 视图模型
@MainActor
class FinancialDashboardViewModel: ObservableObject {
    @Published var selectedDate: Date?
    @Published var selectedCategory: String?
    @Published var chartType: ChartType = .overview
    
    enum ChartType: String, CaseIterable {
        case overview = "概览"
        case category = "分类"
        case trend = "趋势"
    }
    
    let weeklyData: [FinancialData] = [
        FinancialData(date: Date().addingTimeInterval(-6*86400), income: 1200, expense: 800, category: "收入"),
        FinancialData(date: Date().addingTimeInterval(-5*86400), income: 1300, expense: 750, category: "支出"),
        FinancialData(date: Date().addingTimeInterval(-4*86400), income: 1100, expense: 900, category: "收入"),
        FinancialData(date: Date().addingTimeInterval(-3*86400), income: 1400, expense: 850, category: "支出"),
        FinancialData(date: Date().addingTimeInterval(-2*86400), income: 1250, expense: 780, category: "收入"),
        FinancialData(date: Date().addingTimeInterval(-1*86400), income: 1350, expense: 820, category: "支出"),
        FinancialData(date: Date(), income: 1500, expense: 700, category: "收入")
    ]
    
    let categoryData: [(name: String, amount: Double, color: Color)] = [
        ("餐饮", 3200, .orange),
        ("购物", 2500, .blue),
        ("交通", 1200, .green),
        ("娱乐", 1800, .purple),
        ("其他", 800, .gray)
    ]
    
    var totalIncome: Double {
        weeklyData.filter { $0.category == "收入" }.reduce(0) { $0 + $1.income }
    }
    
    var totalExpense: Double {
        weeklyData.filter { $0.category == "支出" }.reduce(0) { $0 + $1.expense }
    }
    
    var balance: Double {
        totalIncome - totalExpense
    }
}

// MARK: - 主视图
struct FinancialDashboardView: View {
    @StateObject private var viewModel = FinancialDashboardViewModel()
    @State private var selectedMetric: Metric = .overview
    
    enum Metric: String, CaseIterable {
        case overview = "概览"
        case categories = "分类"
        case trend = "趋势"
    }
    
    var body: some View {
        NavigationView {
            ScrollView {
                VStack(spacing: 20) {
                    // 摘要卡片
                    SummaryCard(
                        income: viewModel.totalIncome,
                        expense: viewModel.totalExpense,
                        balance: viewModel.balance
                    )
                    .padding(.horizontal)
                    
                    // 图表类型选择器
                    Picker("图表类型", selection: $viewModel.chartType) {
                        ForEach(FinancialDashboardViewModel.ChartType.allCases, id: \.self) { type in
                            Text(type.rawValue).tag(type)
                        }
                    }
                    .pickerStyle(.segmented)
                    .padding(.horizontal)
                    
                    // 动态图表
                    switch viewModel.chartType {
                    case .overview:
                        IncomeExpenseChart(data: viewModel.weeklyData, selectedDate: $viewModel.selectedDate)
                    case .category:
                        CategoryChart(data: viewModel.categoryData)
                    case .trend:
                        TrendChart(data: viewModel.weeklyData)
                    }
                    
                    // 最近交易
                    RecentTransactionsView()
                        .padding(.horizontal)
                }
                .padding(.vertical)
            }
            .background(Color(.systemGray6))
            .navigationTitle("财务仪表盘")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Menu {
                        Button("导出报告") { }
                        Button("刷新数据") { }
                        Button("设置预算") { }
                    } label: {
                        Image(systemName: "ellipsis.circle")
                    }
                }
            }
        }
    }
}

// MARK: - 摘要卡片
struct SummaryCard: View {
    let income: Double
    let expense: Double
    let balance: Double
    
    var body: some View {
        VStack(spacing: 16) {
            Text("本月汇总")
                .font(.headline)
            
            HStack(spacing: 20) {
                SummaryItem(
                    title: "收入",
                    amount: income,
                    color: .green,
                    icon: "arrow.up.circle.fill"
                )
                
                SummaryItem(
                    title: "支出",
                    amount: expense,
                    color: .red,
                    icon: "arrow.down.circle.fill"
                )
                
                SummaryItem(
                    title: "结余",
                    amount: balance,
                    color: balance >= 0 ? .green : .red,
                    icon: "equal.circle.fill"
                )
            }
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(16)
        .shadow(color: .black.opacity(0.05), radius: 5, x: 0, y: 2)
    }
}

struct SummaryItem: View {
    let title: String
    let amount: Double
    let color: Color
    let icon: String
    
    var body: some View {
        VStack(spacing: 8) {
            Image(systemName: icon)
                .foregroundColor(color)
            
            Text(formatAmount(amount))
                .font(.headline)
                .bold()
            
            Text(title)
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .frame(maxWidth: .infinity)
    }
    
    private func formatAmount(_ amount: Double) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.locale = Locale.current
        return formatter.string(from: NSNumber(value: amount)) ?? "¥0"
    }
}

// MARK: - 收支对比图
struct IncomeExpenseChart: View {
    let data: [FinancialData]
    @Binding var selectedDate: Date?
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("收支对比")
                .font(.headline)
                .padding(.leading)
            
            Chart {
                ForEach(data) { item in
                    if item.category == "收入" {
                        BarMark(
                            x: .value("日期", item.dateString),
                            y: .value("金额", item.income)
                        )
                        .foregroundStyle(.green.gradient)
                        .position(by: .value("类型", "收入"))
                    } else {
                        BarMark(
                            x: .value("日期", item.dateString),
                            y: .value("金额", item.expense)
                        )
                        .foregroundStyle(.red.gradient)
                        .position(by: .value("类型", "支出"))
                    }
                }
            }
            .frame(height: 250)
            .padding()
            .background(Color(.systemBackground))
            .cornerRadius(16)
            .chartLegend(position: .top, alignment: .trailing)
            .chartXSelection(value: $selectedDate)
        }
    }
}

// MARK: - 分类统计图
struct CategoryChart: View {
    let data: [(name: String, amount: Double, color: Color)]
    
    var total: Double {
        data.reduce(0) { $0 + $1.amount }
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("支出分类")
                .font(.headline)
                .padding(.leading)
            
            Chart(data, id: \.name) { item in
                SectorMark(
                    angle: .value("金额", item.amount),
                    innerRadius: .ratio(0.5),
                    outerRadius: .ratio(0.8),
                    angularInset: 2
                )
                .foregroundStyle(by: .value("分类", item.name))
                .annotation(position: .overlay) {
                    if item.amount / total > 0.1 {
                        Text("\(Int(item.amount / total * 100))%")
                            .font(.caption)
                            .bold()
                    }
                }
            }
            .frame(height: 250)
            .padding()
            .background(Color(.systemBackground))
            .cornerRadius(16)
            .chartLegend(position: .bottom, alignment: .center)
            
            // 分类明细
            VStack(spacing: 8) {
                ForEach(data, id: \.name) { item in
                    HStack {
                        Circle()
                            .fill(item.color)
                            .frame(width: 8, height: 8)
                        Text(item.name)
                            .font(.caption)
                        Spacer()
                        Text(formatAmount(item.amount))
                            .font(.caption)
                            .bold()
                        Text("(\(Int(item.amount / total * 100))%)")
                            .font(.caption2)
                            .foregroundColor(.secondary)
                    }
                }
            }
            .padding()
            .background(Color(.systemBackground))
            .cornerRadius(16)
        }
    }
    
    private func formatAmount(_ amount: Double) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.locale = Locale.current
        return formatter.string(from: NSNumber(value: amount)) ?? "¥0"
    }
}

// MARK: - 趋势图
struct TrendChart: View {
    let data: [FinancialData]
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("收支趋势")
                .font(.headline)
                .padding(.leading)
            
            Chart {
                ForEach(data) { item in
                    LineMark(
                        x: .value("日期", item.dateString),
                        y: .value("收入", item.income)
                    )
                    .foregroundStyle(.green)
                    .lineStyle(StrokeStyle(lineWidth: 2))
                    .symbol(Circle().strokeBorder(lineWidth: 2))
                    
                    LineMark(
                        x: .value("日期", item.dateString),
                        y: .value("支出", item.expense)
                    )
                    .foregroundStyle(.red)
                    .lineStyle(StrokeStyle(lineWidth: 2))
                    .symbol(Circle().strokeBorder(lineWidth: 2))
                }
            }
            .frame(height: 250)
            .padding()
            .background(Color(.systemBackground))
            .cornerRadius(16)
            .chartLegend(position: .top, alignment: .trailing)
            .chartYScale(domain: 500...2000)
            
            // 趋势指标
            HStack(spacing: 20) {
                TrendIndicator(
                    title: "收入趋势",
                    change: +8.5,
                    isPositive: true,
                    color: .green
                )
                TrendIndicator(
                    title: "支出趋势",
                    change: -3.2,
                    isPositive: true,
                    color: .red
                )
                TrendIndicator(
                    title: "结余趋势",
                    change: +15.2,
                    isPositive: true,
                    color: .blue
                )
            }
            .padding()
            .background(Color(.systemBackground))
            .cornerRadius(16)
        }
    }
}

struct TrendIndicator: View {
    let title: String
    let change: Double
    let isPositive: Bool
    let color: Color
    
    var body: some View {
        VStack(spacing: 8) {
            Text(title)
                .font(.caption)
                .foregroundColor(.secondary)
            
            HStack(spacing: 4) {
                Image(systemName: change >= 0 ? "arrow.up" : "arrow.down")
                    .font(.caption)
                Text(String(format: "%.1f%%", abs(change)))
                    .font(.headline)
                    .bold()
            }
            .foregroundColor(change >= 0 ? .green : .red)
        }
        .frame(maxWidth: .infinity)
    }
}

// MARK: - 最近交易视图
struct RecentTransactionsView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("最近交易")
                .font(.headline)
            
            ForEach(0..<5) { i in
                TransactionRow(
                    title: i % 2 == 0 ? "超市购物" : "餐饮消费",
                    amount: Double.random(in: 50...500),
                    date: Date(),
                    type: .expense,
                    category: i % 2 == 0 ? "购物" : "餐饮"
                )
                
                if i < 4 {
                    Divider()
                }
            }
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(16)
        .shadow(color: .black.opacity(0.05), radius: 5, x: 0, y: 2)
    }
}

struct TransactionRow: View {
    let title: String
    let amount: Double
    let date: Date
    let type: TransactionType
    let category: String
    
    enum TransactionType {
        case income, expense
    }
    
    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(title)
                    .font(.subheadline)
                Text(category)
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }
            
            Spacer()
            
            VStack(alignment: .trailing, spacing: 4) {
                Text(formatAmount(amount))
                    .font(.subheadline)
                    .bold()
                    .foregroundColor(type == .expense ? .red : .green)
                Text(date, format: .dateTime.month().day())
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }
        }
        .padding(.vertical, 4)
    }
    
    private func formatAmount(_ amount: Double) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.locale = Locale.current
        return formatter.string(from: NSNumber(value: amount)) ?? "¥0"
    }
}

// MARK: - 应用入口
@main
struct FinancialDashboardApp: App {
    var body: some Scene {
        WindowGroup {
            FinancialDashboardView()
        }
    }
}

六、总结

今天的内容涵盖了SwiftUI数据可视化的高级技巧:

类别 关键技术
组合图表 多Mark叠加、双轴图表
交互图表 图表情景、手势缩放、自定义标注
实时数据 动态更新、流式数据、动画过渡
组件化 可复用组件、配置协议、环境值
实战项目 完整财务仪表盘

数据可视化让您的应用能够直观地展示复杂数据。掌握这些高级技巧后,您将能够构建出专业级的数据分析仪表盘,帮助用户洞察信息!📊

相关推荐
jerrywus20 小时前
别再陪 AI 调 iOS 了:用 cmux + baguette,让 Claude 在你的模拟器里"自己动手"
前端·ios·claude
MonkeyKing715521 小时前
iOS 开发 Block 底层结构、循环引用及解决方案
ios·面试
文件夹__iOS21 小时前
Swift 5.9 被严重低估的特性:参数包,一次性干掉重复泛型重载
ios·swiftui·swift
薛定猫AI21 小时前
【技术干货】用 AI + Expo 打通 iOS / Android / Web 跨端应用开发:从架构到代码生成实战
android·人工智能·ios
MonkeyKing1 天前
iOS关联对象底层实现与内存管理细节
ios
90后的晨仔2 天前
SwiftUI 高级特性第2章:组合与容器
ios
pop_xiaoli2 天前
【iOS】SDWebImage源码
macos·ios·objective-c·cocoa
MonkeyKing3 天前
消息发送与转发流程
ios
移动端小伙伴3 天前
我受够了 Xcode 的 SPM 网络问题,写了个脚本一劳永逸
ios