Created
September 8, 2025 08:58
-
-
Save jerrypm/1bfe0d1398c5bbfc1c03aefd690e4146 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| import Charts | |
| import Combine | |
| // MARK: - Custom Chart Symbols | |
| struct SelectionCircleSymbol: ChartSymbolShape { | |
| func path(in rect: CGRect) -> Path { | |
| let center = CGPoint(x: rect.midX, y: rect.midY) | |
| let radius = min(rect.width, rect.height) / 2 | |
| var path = Path() | |
| path.addEllipse(in: CGRect( | |
| x: center.x - radius, | |
| y: center.y - radius, | |
| width: radius * 2, | |
| height: radius * 2 | |
| )) | |
| return path | |
| } | |
| var perceptualUnitRect: CGRect { | |
| return CGRect(x: 0, y: 0, width: 1, height: 1) | |
| } | |
| } | |
| // MARK: - Data Models | |
| struct ChartDataPoint: Identifiable, Equatable { | |
| let id = UUID() | |
| let xLabel: String | |
| let value: Double | |
| let date: Date | |
| init(xLabel: String, value: Double, date: Date = Date()) { | |
| self.xLabel = xLabel | |
| self.value = value | |
| self.date = date | |
| } | |
| } | |
| struct ChartConfiguration { | |
| let showGrid: Bool = true | |
| let showDots: Bool = true | |
| let showFill: Bool = true | |
| let lineWidth: Double = 3.0 | |
| let dotRadius: Double = 4.0 | |
| let animationDuration: Double = 0.8 | |
| let enableSelection: Bool = true | |
| // Colors | |
| let lineColor: Color = .blue | |
| let dotColor: Color = .blue | |
| let fillTopColor: Color = Color.blue.opacity(0.3) | |
| let fillBottomColor: Color = Color.blue.opacity(0.05) | |
| let gridColor: Color = Color.gray.opacity(0.3) | |
| let selectionColor: Color = .blue | |
| } | |
| // MARK: - Chart View Model | |
| class ChartViewModel: ObservableObject { | |
| @Published var chartData: [ChartDataPoint] = [] | |
| @Published var selectedPoint: ChartDataPoint? | |
| @Published var isAnimating = false | |
| let configuration = ChartConfiguration() | |
| init() { | |
| loadSalesData() | |
| } | |
| private func loadSalesData() { | |
| let salesData = [ | |
| ("Jan", 12500.0), ("Feb", 15800.0), ("Mar", 18200.0), | |
| ("Apr", 21000.0), ("May", 19500.0), ("Jun", 23400.0), | |
| ("Jul", 25600.0), ("Aug", 22800.0), ("Sep", 27200.0), | |
| ("Oct", 24800.0), ("Nov", 28900.0), ("Dec", 31500.0) | |
| ] | |
| chartData = salesData.enumerated().map { index, data in | |
| let date = Calendar.current.date(from: DateComponents(year: 2024, month: index + 1)) ?? Date() | |
| return ChartDataPoint(xLabel: data.0, value: data.1, date: date) | |
| } | |
| } | |
| func refreshData() { | |
| withAnimation(.easeInOut(duration: configuration.animationDuration)) { | |
| isAnimating = true | |
| let newData = chartData.map { point in | |
| let randomMultiplier = Double.random(in: 0.7...1.3) | |
| return ChartDataPoint( | |
| xLabel: point.xLabel, | |
| value: point.value * randomMultiplier, | |
| date: point.date | |
| ) | |
| } | |
| chartData = newData | |
| selectedPoint = nil | |
| } | |
| DispatchQueue.main.asyncAfter(deadline: .now() + configuration.animationDuration) { | |
| self.isAnimating = false | |
| } | |
| } | |
| func selectPoint(_ point: ChartDataPoint?) { | |
| withAnimation(.easeInOut(duration: 0.3)) { | |
| selectedPoint = point | |
| } | |
| } | |
| } | |
| // MARK: - Chart Components | |
| struct ChartGridBackground: View { | |
| let configuration: ChartConfiguration | |
| let showGrid: Bool | |
| var body: some View { | |
| if showGrid { | |
| VStack(spacing: 0) { | |
| ForEach(0..<5, id: \.self) { _ in | |
| Spacer() | |
| Rectangle() | |
| .fill(configuration.gridColor) | |
| .frame(height: 1) | |
| } | |
| Spacer() | |
| } | |
| } | |
| } | |
| } | |
| struct SelectionIndicator: View { | |
| let selectedPoint: ChartDataPoint | |
| let configuration: ChartConfiguration | |
| let onClear: () -> Void | |
| var body: some View { | |
| VStack { | |
| HStack { | |
| VStack(alignment: .leading, spacing: 4) { | |
| Text(selectedPoint.xLabel) | |
| .font(.caption) | |
| .foregroundColor(.secondary) | |
| Text("$\(selectedPoint.value, specifier: "%.0f")") | |
| .font(.title2) | |
| .fontWeight(.bold) | |
| .foregroundColor(configuration.lineColor) | |
| } | |
| Spacer() | |
| Button("Clear") { | |
| onClear() | |
| } | |
| .font(.caption) | |
| .foregroundColor(configuration.selectionColor) | |
| } | |
| .padding(.horizontal, 12) | |
| .padding(.vertical, 8) | |
| .background( | |
| RoundedRectangle(cornerRadius: 8) | |
| .fill(.ultraThinMaterial) | |
| .shadow(radius: 4, y: 2) | |
| ) | |
| Spacer() | |
| } | |
| .transition(.opacity.combined(with: .scale(scale: 0.95))) | |
| } | |
| } | |
| struct ConfigToggle: View { | |
| let title: String | |
| let systemImage: String | |
| @Binding var isOn: Bool | |
| var body: some View { | |
| Button(action: { | |
| withAnimation(.easeInOut(duration: 0.2)) { | |
| isOn.toggle() | |
| } | |
| }) { | |
| HStack(spacing: 6) { | |
| Image(systemName: systemImage) | |
| .font(.caption) | |
| Text(title) | |
| .font(.caption) | |
| .fontWeight(.medium) | |
| } | |
| .foregroundColor(isOn ? .white : .primary) | |
| .padding(.horizontal, 10) | |
| .padding(.vertical, 6) | |
| .background( | |
| RoundedRectangle(cornerRadius: 6) | |
| .fill(isOn ? Color.blue : Color.gray.opacity(0.1)) | |
| .overlay( | |
| RoundedRectangle(cornerRadius: 6) | |
| .stroke(Color.gray.opacity(0.3), lineWidth: 1) | |
| ) | |
| ) | |
| } | |
| .buttonStyle(PlainButtonStyle()) | |
| } | |
| } | |
| struct ChartControlsView: View { | |
| @ObservedObject var viewModel: ChartViewModel | |
| @Binding var showDots: Bool | |
| @Binding var showFill: Bool | |
| var body: some View { | |
| VStack(spacing: 16) { | |
| // Title and Refresh Button | |
| HStack { | |
| Text("Revenue Analytics") | |
| .font(.title2) | |
| .fontWeight(.bold) | |
| Spacer() | |
| Button(action: { | |
| viewModel.refreshData() | |
| }) { | |
| HStack(spacing: 4) { | |
| Image(systemName: "arrow.clockwise") | |
| Text("Refresh") | |
| } | |
| .font(.caption) | |
| .foregroundColor(.white) | |
| .padding(.horizontal, 12) | |
| .padding(.vertical, 6) | |
| .background( | |
| RoundedRectangle(cornerRadius: 6) | |
| .fill(viewModel.configuration.lineColor) | |
| ) | |
| } | |
| .disabled(viewModel.isAnimating) | |
| .scaleEffect(viewModel.isAnimating ? 0.95 : 1.0) | |
| .animation(.easeInOut(duration: 0.2), value: viewModel.isAnimating) | |
| } | |
| // Chart Configuration Toggle | |
| ScrollView(.horizontal, showsIndicators: false) { | |
| HStack(spacing: 12) { | |
| ConfigToggle( | |
| title: "Dots", | |
| systemImage: "circle.fill", | |
| isOn: $showDots | |
| ) | |
| ConfigToggle( | |
| title: "Fill", | |
| systemImage: "chart.line.uptrend.xyaxis.circle.fill", | |
| isOn: $showFill | |
| ) | |
| } | |
| .padding(.horizontal, 4) | |
| } | |
| } | |
| .padding(.horizontal) | |
| } | |
| } | |
| // MARK: - Main Chart View | |
| struct ExpertChartView: View { | |
| @StateObject private var viewModel = ChartViewModel() | |
| @State private var selectedDataPoint: ChartDataPoint? | |
| @State private var animationProgress: Double = 0 | |
| // Configuration toggles | |
| @State private var showDots: Bool = true | |
| @State private var showFill: Bool = true | |
| var body: some View { | |
| NavigationStack { | |
| VStack(spacing: 0) { | |
| // Controls Section | |
| ChartControlsView( | |
| viewModel: viewModel, | |
| showDots: $showDots, | |
| showFill: $showFill | |
| ) | |
| .padding(.top) | |
| // Chart Section | |
| ZStack { | |
| // Main Chart | |
| Chart(viewModel.chartData) { dataPoint in | |
| if showFill { | |
| AreaMark( | |
| x: .value("Month", dataPoint.xLabel), | |
| y: .value("Revenue", dataPoint.value) | |
| ) | |
| .foregroundStyle( | |
| LinearGradient( | |
| colors: [ | |
| viewModel.configuration.fillTopColor, | |
| viewModel.configuration.fillBottomColor | |
| ], | |
| startPoint: .top, | |
| endPoint: .bottom | |
| ) | |
| ) | |
| .opacity(animationProgress) | |
| } | |
| LineMark( | |
| x: .value("Month", dataPoint.xLabel), | |
| y: .value("Revenue", dataPoint.value) | |
| ) | |
| .foregroundStyle(viewModel.configuration.lineColor) | |
| .lineStyle(StrokeStyle(lineWidth: viewModel.configuration.lineWidth, lineCap: .round, lineJoin: .round)) | |
| .symbol(.circle) | |
| .symbolSize(showDots ? viewModel.configuration.dotRadius * 15 : 0) | |
| } | |
| .frame(height: 300) | |
| .padding(.top, 44) | |
| .chartXAxis { | |
| AxisMarks(values: .automatic) { _ in | |
| AxisGridLine(stroke: StrokeStyle(lineWidth: 1)) | |
| .foregroundStyle(viewModel.configuration.gridColor) | |
| AxisTick() | |
| AxisValueLabel() | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| } | |
| .chartYAxis { | |
| AxisMarks(position: .leading, values: .automatic) { value in | |
| AxisGridLine(stroke: StrokeStyle(lineWidth: 1)) | |
| .foregroundStyle(viewModel.configuration.gridColor) | |
| AxisTick() | |
| AxisValueLabel() { | |
| if let doubleValue = value.as(Double.self) { | |
| Text("$\(doubleValue / 1000, specifier: "%.0f")K") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| .padding(.horizontal) | |
| .onAppear { | |
| startAnimation() | |
| } | |
| .onChange(of: viewModel.chartData) { | |
| selectedDataPoint = nil | |
| startAnimation() | |
| } | |
| Spacer() | |
| } | |
| .navigationBarBackButtonHidden(true) | |
| .navigationBarTitleDisplayMode(.inline) | |
| .background( | |
| LinearGradient( | |
| colors: [ | |
| Color.blue.opacity(0.05), | |
| Color.purple.opacity(0.05) | |
| ], | |
| startPoint: .topLeading, | |
| endPoint: .bottomTrailing | |
| ) | |
| .ignoresSafeArea() | |
| ) | |
| } | |
| } | |
| private func startAnimation() { | |
| animationProgress = 0 | |
| withAnimation(.easeInOut(duration: viewModel.configuration.animationDuration)) { | |
| animationProgress = 1 | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment