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")) | |
} | |
} |
@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
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...