Skip to content

Instantly share code, notes, and snippets.

@beader
Last active January 29, 2024 16:25
Show Gist options
  • Save beader/8779526f466e373a9085338742919d9b to your computer and use it in GitHub Desktop.
Save beader/8779526f466e373a9085338742919d9b to your computer and use it in GitHub Desktop.
Infinite Scrollable Bar Chart using Swift Charts

Infinite Scrollable Bar Chart using Swift Charts

Just like charts in the official Health App. You can:

  • Slow swipe by day
  • Fast swipe by page

Check the video demo in the comment.

//
// InfiniteScrollChart.swift
// ChartsGallery
//
// Created by beader on 2022/11/3.
//
import SwiftUI
import Charts
struct YAxisWidthPreferenceyKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct YAxisWidthModifier: ViewModifier {
func body(content: Content) -> some View {
content.background(
GeometryReader { geometry in
Color.clear.preference(key: YAxisWidthPreferenceyKey.self, value: geometry.size.width)
}
)
}
}
struct BarChart: View {
@Binding var unitOffset: Int
@State var upperBound: Double?
init(unitOffset: Binding<Int>) {
self._unitOffset = unitOffset
}
private let calendar: Calendar = {
Calendar.init(identifier: .gregorian)
}()
private var initDate: Date {
calendar.startOfDay(for: Date().addingTimeInterval(TimeInterval(unitOffset * 24 * 3600)))
}
private var data: [(date: Date, value: Double)] {
return (-7..<14).map { i in
(date: initDate.addingTimeInterval(Double(i) * 24 * 3600), value: abs(Double(i + unitOffset) * 10))
}
}
var body: some View {
Chart {
ForEach(data, id: \.date) { item in
BarMark(
x: .value("Day", item.date, unit: .weekday),
y: .value("Value", min(item.value, upperBound ?? item.value))
)
}
}
.onAppear {
upperBound = data[7..<14].map(\.value).max()
}
.onChange(of: unitOffset) { newValue in
withAnimation(.spring()) {
upperBound = data[7..<14].map(\.value).max()
}
}
}
}
struct InfiniteScrollChart: View {
private let height: CGFloat = 250
private let numBins: Int = 7
private let pagingAnimationDuration: CGFloat = 0.2
@GestureState private var translation: CGFloat = .zero
@State private var offset: CGFloat = .zero
// Width of the visible plot area
@State private var chartContentContainerWidth: CGFloat = .zero
// Width of the yAxis of chart
@State private var yAxisWidth: CGFloat = .zero
// Each bar represents a unit duration along xAxis
@State private var currentUnitOffset: Int = .zero
@Environment(\.locale) var locale
private var drag: some Gesture {
DragGesture(minimumDistance: 0)
.updating($translation) { value, state, _ in
state = value.translation.width
}
.onEnded { value in
offset = offset + value.translation.width
let unitWidth = chartContentContainerWidth / Double(numBins)
let unitOffset = (value.translation.width / unitWidth).rounded(.toNearestOrAwayFromZero)
var predictedUnitOffset = (value.predictedEndTranslation.width / unitWidth).rounded(.toNearestOrAwayFromZero)
// If swipe carefully, change to the nearest time unit
// If swipe fast enough, change to the next page
predictedUnitOffset = max(-Double(numBins), predictedUnitOffset)
predictedUnitOffset = min(Double(numBins), predictedUnitOffset)
withAnimation(.easeOut(duration: pagingAnimationDuration)) {
if abs(predictedUnitOffset) >= Double(numBins) {
offset = predictedUnitOffset * unitWidth
} else {
offset = unitOffset * unitWidth
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + pagingAnimationDuration) {
currentUnitOffset = currentUnitOffset - Int(offset / unitWidth)
offset = 0
}
}
}
var body: some View {
GeometryReader { geometry in
HStack(alignment: .top, spacing: 0) {
VStack(spacing: 0) {
chartContent
// The actual width of the plot area is three times of page width
.frame(width: chartContentContainerWidth * 3, height: height)
.offset(x: translation)
.offset(x: offset)
.gesture(drag)
// This is a magic component to avoid some weird UI behavior
Text("")
}
.frame(width: chartContentContainerWidth)
.clipped()
chartYAxis
.modifier(YAxisWidthModifier())
.onPreferenceChange(YAxisWidthPreferenceyKey.self) { newValue in
yAxisWidth = newValue
chartContentContainerWidth = geometry.size.width - yAxisWidth
}
}
}
.frame(height: height)
}
var chart: some View {
BarChart(unitOffset: $currentUnitOffset)
}
var chartContent: some View {
chart
.chartXAxis {
AxisMarks(
format: .dateTime.weekday().locale(locale),
preset: .extended,
values: .stride(by: .day)
)
}
.chartYAxis {
AxisMarks(position: .trailing, values: .automatic(desiredCount: 4)) {
AxisGridLine()
}
}
}
var chartYAxis: some View {
chart
.foregroundStyle(.clear)
.chartYAxis {
AxisMarks(position: .trailing, values: .automatic(desiredCount: 4))
}
.chartPlotStyle { plot in
plot.frame(width: 0)
}
}
}
struct InfinityScrollChart_Previews: PreviewProvider {
static var previews: some View {
InfiniteScrollChart()
.padding(.horizontal, 4)
.environment(\.locale, .init(identifier: "zh"))
}
}
@beader
Copy link
Author

beader commented Jun 21, 2023

In WWDC23, scrollable charts are officially supported in iOS 17.
Explore pie charts and interactivity in Swift Charts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment