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")) | |
} | |
} |
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, 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
.
@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 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.
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...
@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:
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
}
}
In WWDC23, scrollable charts are officially supported in iOS 17.
Explore pie charts and interactivity in Swift Charts
Simulator.Screen.Recording.-.iPhone.14.-.2022-11-07.at.11.32.22.mp4