欢迎来到本系列教程的第四十五篇。在前四十四篇文章中,你已经学习了从Swift基础到App Store提交的全方位iOS开发技能。现在,你能够构建出完整的、可上架的iOS应用了。在本篇中,我们将深入探索SwiftUI数据可视化的高级技巧,帮助你构建能够吸引用户的交互式图表和仪表盘。
在这一篇中,你将学到:
-
高级图表技巧
-
组合图表
-
动态数据更新
-
图表主题与样式
-
-
交互式图表
-
图表情景
-
手势与细节展示
-
缩放与平移
-
-
实时数据流
-
动态更新图表
-
动画过渡
-
-
自定义图表组件
-
创建可复用图表组件
-
图表配置协议
-
-
实战项目:构建完整的财务仪表盘
一、高级图表技巧
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叠加、双轴图表 |
| 交互图表 | 图表情景、手势缩放、自定义标注 |
| 实时数据 | 动态更新、流式数据、动画过渡 |
| 组件化 | 可复用组件、配置协议、环境值 |
| 实战项目 | 完整财务仪表盘 |
数据可视化让您的应用能够直观地展示复杂数据。掌握这些高级技巧后,您将能够构建出专业级的数据分析仪表盘,帮助用户洞察信息!📊