Skip to content

Instantly share code, notes, and snippets.

@beader
Last active August 28, 2024 18:16
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 Nov 7, 2022

Simulator.Screen.Recording.-.iPhone.14.-.2022-11-07.at.11.32.22.mp4

@rogervdberg
Copy link

Hi, thanks for this code. I intend to use this idea in my app. But there is one thing I can't get my head around. When the chart first shows, it shows elements 7..<14 from data. Why doesn't it show the first 7 elements? Has it to do with expanding the frame of the chartContent?

@beader
Copy link
Author

beader commented Feb 19, 2023

Hi, thanks for this code. I intend to use this idea in my app. But there is one thing I can't get my head around. When the chart first shows, it shows elements 7..<14 from data. Why doesn't it show the first 7 elements? Has it to do with expanding the frame of the chartContent?

Hi, @rogervdberg . The width of the "big" chart is three times as the screen's width. I hope this chart can do infinite scrolling on both side. If 0..<21 is on the "big" chart, the middle part of the chart will show 7..<14. The left part of the chart which is no visible on the screen will show data from 0..<7.

@rogervdberg
Copy link

@beader Ok, I understand what your saying. But why does it initially show the middle part of the big chart and not the first 7 elements? Is it something Swift does automatically?

@beader
Copy link
Author

beader commented Feb 19, 2023

@beader Ok, I understand what your saying. But why does it initially show the middle part of the big chart and not the first 7 elements? Is it something Swift does automatically?

To make it left-scrollable.

@iamgp
Copy link

iamgp commented Jun 20, 2023

What happens if the data you have is current, i.e. the chart can't be scrolled left and right, and you would like to be able to scroll left going backwards in time? The offset doesn't seem to work...

@MFilippini
Copy link

MFilippini commented Jun 21, 2023

@iamgp
I added a spring-like pull once you reach the right-most part of the graph.

Some other changes in this code you may want to parse out:

  • Easier to flick to next week (harder to slow drag)
  • When you flick it goes to an offset of 7
    (ex. For a Monday start week. If you slow drag back to see Sunday, then flick back a week, you end up on another Monday not 7 days before the Sunday you looked at. I believe this is the behavior in Apples Health App)
DragGesture(minimumDistance: 0)
        .updating($translation) { value, state, _ in
            state = calculateTranslationFromDrag(dragWidth: value.translation.width)
        }
        .onEnded { value in
            let realTranslation = calculateTranslationFromDrag(dragWidth: value.translation.width)

            offset = offset + realTranslation
            
            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
            
            if unitOffset != predictedUnitOffset {
                predictedUnitOffset = copysign(Double(numBins), predictedUnitOffset)
            }
            
            if abs(predictedUnitOffset) >= Double(numBins) {
                predictedUnitOffset = copysign(Double(numBins), predictedUnitOffset)
            
                let expectedOffset = currentUnitOffset - Int(predictedUnitOffset)
                let nearestNewPage = Double(numBins) * (Double(expectedOffset) / Double(numBins)).rounded(.toNearestOrAwayFromZero)
                predictedUnitOffset = Double(currentUnitOffset) - nearestNewPage
            }
            
            if currentUnitOffset - Int(predictedUnitOffset) > 0 {
                predictedUnitOffset = (CGFloat)(currentUnitOffset)
            }
            
            withAnimation(.easeOut(duration: pagingAnimationDuration)) {
                offset = predictedUnitOffset * unitWidth
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + pagingAnimationDuration) {
                currentUnitOffset = currentUnitOffset - Int(offset / unitWidth)
                offset = 0
            }
        }


private func calculateTranslationFromDrag(dragWidth: CGFloat) -> CGFloat {
      let unitWidth = chartContentContainerWidth / Double(numBins)
        let currentOffset = CGFloat(currentUnitOffset)
        if currentOffset - (dragWidth/unitWidth) > 0 {
            let backToZeroWidth = currentOffset*unitWidth
            let extraWidth = currentOffset - dragWidth/unitWidth
            let springPullWidth = (extraWidth + 1).squareRoot() - 1
            
            return backToZeroWidth - (springPullWidth * unitWidth)
        }
        else {
            return dragWidth
        }
    }

@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