Skip to content

Instantly share code, notes, and snippets.

@jerrypm
Created September 8, 2025 08:58
Show Gist options
  • Select an option

  • Save jerrypm/1bfe0d1398c5bbfc1c03aefd690e4146 to your computer and use it in GitHub Desktop.

Select an option

Save jerrypm/1bfe0d1398c5bbfc1c03aefd690e4146 to your computer and use it in GitHub Desktop.
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