Last active
May 19, 2025 08:38
-
-
Save Midbin/c275098ed1151e51a0f3441ea69f921f to your computer and use it in GitHub Desktop.
A big and long but performant scrolling Swift Chart
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 GameplayKit | |
let gaussianRandoms = GKGaussianDistribution(lowestValue: 0, highestValue: 20) | |
func date(year: Int, month: Int, day: Int = 1) -> Date { | |
Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) ?? Date() | |
} | |
func randomSalesForDay(_ dayNumber: Double) -> Int { | |
// Add noise to the generated data. | |
let yearlySeasonality = 100.0 * (0.5 - 0.5 * cos(2.0 * .pi * (dayNumber / 364.0))) | |
let monthlySeasonality = 10.0 * (0.5 - 0.5 * cos(2.0 * .pi * (dayNumber / 30.0))) | |
let weeklySeasonality = 30.0 * (1 - cos(2.0 * .pi * ((dayNumber + 2.0) / 7.0))) | |
return Int(yearlySeasonality + monthlySeasonality + weeklySeasonality + Double(gaussianRandoms.nextInt())) | |
} | |
let allData: [(day: Date, sales: Int)] = stride(from: 0, to: 7283, by: 1).compactMap { | |
let startDay: Date = date(year: 2003, month: 07, day: 01) // 200 days before WWDC | |
let day: Date = Calendar.current.date(byAdding: .day, value: $0, to: startDay)! | |
let dayNumber = Double($0) | |
var sales = randomSalesForDay(dayNumber) | |
let dayOfWeek = Calendar.current.component(.weekday, from: day) | |
if dayOfWeek == 6 { | |
sales += gaussianRandoms.nextInt() * 3 | |
} else if dayOfWeek == 7 { | |
sales += gaussianRandoms.nextInt() | |
} else { | |
sales = Int(Double(sales) * Double.random(in: 4...5) / Double.random(in: 5...6)) | |
} | |
return ( | |
day: day, | |
sales: sales | |
) | |
} | |
func daysAround(from startDay: Date) -> [(day: Date, sales: Int)] { | |
allData.filter{$0.day > startDay - 3600 * 24 * 70 && $0.day < startDay + 3600 * 24 * 70} | |
} | |
func marksAround(from startDay: Date, bounds: Range<Date>) -> [Date] { | |
let cal = Calendar.current | |
let totalStart = cal.date(byAdding: .day, value: -31, to: startDay)! | |
let alignedStart = cal.date(bySettingHour: 0, minute: 0, second: 0, of: totalStart)! | |
//Should be calendar week to be persistant but demonstrates the basics | |
return stride(from: 0, to: 93, by: 7).map { | |
cal.date(byAdding: .day, value: $0, to: alignedStart)! | |
}.filter{bounds.contains($0)} | |
} | |
struct ContentView: View { | |
@State var scrollPosition: TimeInterval = date(year: 2023, month: 05, day: 01).timeIntervalSinceReferenceDate | |
@State var lastScrollPosition = date(year: 2023, month: 05, day: 01).timeIntervalSinceReferenceDate | |
@State var data = daysAround(from: date(year: 2023, month: 05, day: 01)) | |
@State var dataBounds = date(year: 2003, month: 07, day: 01) ..< date(year: 2023, month: 06, day: 30) // Lets say we have daily Data for the last 20 Years | |
@State var chartMarks = marksAround(from: date(year: 2023, month: 05, day: 01), bounds: date(year: 1900, month: 07, day: 01) ..< date(year: 2023, month: 06, day: 30)) | |
@ChartContentBuilder | |
var bounds: some ChartContent { | |
PointMark(x: .value("Left Bound", dataBounds.lowerBound)) | |
.opacity(0) | |
.accessibilityHidden(true) | |
PointMark(x: .value("Right Bound", dataBounds.upperBound)) | |
.opacity(0) | |
.accessibilityHidden(true) | |
} | |
var body: some View { | |
Chart{ | |
bounds | |
ForEach(data, id: \.day) { | |
BarMark( | |
x: .value("Day", $0.day, unit: .day), | |
y: .value("Sales", $0.sales) | |
) | |
} | |
} | |
.chartScrollableAxes(.horizontal) | |
.chartXVisibleDomain(length: 3600 * 24 * 30) | |
.chartScrollTargetBehavior( | |
.valueAligned( | |
matching: .init(hour: 0), | |
majorAlignment: .matching(.init(day: 1)), | |
limitBehavior: .always)) | |
.chartScrollPosition(x: $scrollPosition) | |
.chartXAxis { | |
AxisMarks(values: chartMarks){ | |
AxisTick() | |
AxisGridLine() | |
AxisValueLabel(format: .dateTime.month().day().year()) | |
} | |
} | |
.chartYScale(domain: 0...200) | |
.onChange(of: scrollPosition, initial: false) { | |
if abs(scrollPosition - lastScrollPosition) > 3600 * 24 * 20 { | |
let scrollDate = Date(timeIntervalSinceReferenceDate: scrollPosition) | |
self.data = daysAround(from: scrollDate) | |
self.chartMarks = marksAround(from: scrollDate, bounds: dataBounds) // If the Marks are calculated by Swift Charts the performance gets pretty bad. | |
lastScrollPosition = scrollPosition | |
} | |
} | |
} | |
} | |
#Preview { | |
ContentView() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment