In this exercise, we’ll add a “pinch-to-zoom-in/out” feature to the chart. It will be very basic, but should work quite well.
The Basics
The zoom will be X-axis only. It doesn’t actually make sense to zoom in on the Y-axis, especially since we have the ability to select a bar, and get the exact values from that selection, and we can’t really tell “where we are,” on the Y-axis (we have a similar issue with the X-axis, but it isn’t as bad).
We’ll follow the standard iOS magnification gesture, which is a two-finger pinch, expanded, to zoom in, and a wide, two-finger touch, brought together, to zoom out. We’ll cheat, and show the final result, now, in Figure 4:
How It Will Work
As with the Drag-To-Select functionality, most of the heavy lifting is actually done by the model file (DataProvider.swift
). We’ll cover this file, in Appendix A.
The only thing that we’ll need to do, is set the DataProvider.dataWindowRange
property. Doing this, will automatically trigger a redraw of the chart, with the display showing the new X-axis range (remember that, at the beginning, we used the DataProvider.windowedRows
property to set the X-axis. This is why).
The Magnification Gesture
We’ll be doing something similar to the last step, where we’ll be attaching another gesture to the overlay that we created. This time, we’ll be adding a MagnifyGesture
, which takes the pinch-to-zoom touch, and converts it into locally-relevant data that we can use to modify the DataProvider.dataWindowRange
property.
Step-By-Step
Step One: The Initial Range Property
The Magnification Amount Property
The magnification gesture onChange
closure value parameter will contain a magnification
property, which, unfortunately, is not actually documented, at the time of this writing. What you’ll need to do, to see it, is use Right-Click->Jump-To-Destination, on the MagnifyGesture()
declaration, to see its published interface (ProTip: Remember this. You’ll probably need to use it a lot). In order to save time, I’ll show it here, but be aware that this may change, over time:
public struct MagnifyGesture : Gesture {
/// The type representing the gesture's value.
public struct Value : Equatable, Sendable {
/// The time associated with the gesture's current event.
public var time: Date
/// The relative amount that the gesture has magnified by.
///
/// A value of 2.0 means that the user has interacted with the gesture
/// to increase the magnification by a factor of 2 more than before the
/// gesture.
public var magnification: CGFloat
/// The current magnification velocity.
public var velocity: CGFloat
·
·
·
The DemoChartDisplay._firstRang
e Property
Here’s a new property that we’ll be adding to the top of the DemoChartDisplay.swift
file. We’ll explain it in a minute:
@State private var _firstRange: ClosedRange<Date>? { didSet { if oldValue != _firstRange { _selectedValue = nil } } }
@State private var _data = DataProvider()
Now that we know where we’ll be getting the magnification value from, let’s walk through how the gesture works.
When we first declare the gesture, we’ll use its default MagnifyGesture()
instantiation, which sets a very low amount of movement, before the first onChange
is called.
Upon calling onChange
, for the first time, we save the current DataProvider.dataWindowRange
value, in a new state property, called _firstRange
. This is kept around, for as long as the gesture continues, and is not changed. We also take this opportunity to ensure that any selection still in place, is taken down (should never happen, but we should make sure).
The resulting range, from the magnification, is calculated, based upon this initial range. No change in magnification is a magnification
value of 1.0. Zooming in, 2X, is 2.0, and zooming out, 1/2X, is 0.5. This value will be updated, each time that onChanged
is called.
Once the gesture completes, onEnded
is called, where we return the _firstRange
property to nil, so it will be reinitialized, when the next magnification gesture is executed.
The MagnifyGesture
We will attach the MagnifyGesture, in the same way that we did the DragGesture
, in the previous step, inside a gesture
container:
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { inValue in
if let frame = inChart.plotFrame {
guard let date = inChart.value(atX: max(0, min(inChart.plotSize.width, inValue.location.x - inGeom[frame].origin.x)), as: Date.self) else { return }
_selectedValue = _data.windowedRows.nearestTo(date)
}
}
.onEnded { _ in _selectedValue = nil }
)
.gesture(
MagnifyGesture()
·
·
·
)
The onChanged
Handler
Again, the action will mainly occur in the onChanged callback:
MagnifyGesture()
.onChanged { inValue in
_firstRange = _firstRange ?? _data.dataWindowRange
if let firstRange = _firstRange {
let rangeInSeconds = (firstRange.upperBound.timeIntervalSinceReferenceDate - firstRange.lowerBound.timeIntervalSinceReferenceDate) / 2
let centerDateInSeconds = (TimeInterval(inValue.startAnchor.x) * (rangeInSeconds * 2)) + firstRange.lowerBound.timeIntervalSinceReferenceDate
let centerDate = Calendar.current.startOfDay(for: Date(timeIntervalSinceReferenceDate: centerDateInSeconds)).addingTimeInterval(43200)
let newRange = max(86400, (rangeInSeconds * 1.2) / inValue.magnification)
_data.dataWindowRange = (centerDate.addingTimeInterval(-newRange)...centerDate.addingTimeInterval(newRange)).clamped(to: _data.totalDateRange)
}
}
What happens here, is that we take the original range, saved in the _firstRange property, and apply a magnification coefficient to it. This coefficient is the magnification
property of the Value
, passed into the onChanged
handler.
From this, we calculate a new range, and set the _data.dataWindowRange property to that. Setting this, automatically triggers a redraw of the chart, with the new range.
The values of the X-axis are Date
instances, but the calculation is done in seconds, so we convert the Date
s, to seconds, do the calculation, then return the new range to Date
s. We also clip the range to make sure that it doesn’t extend beyond the minimum and maximum dates in the dataset.
The onEnded
Handler
As we did in the last step, we’ll add an onEnded
handler to clear the _firstRange
property:
MagnifyGesture()
.onChanged { inValue in
_firstRange = _firstRange ?? _data.dataWindowRange
if let firstRange = _firstRange {
let rangeInSeconds = (firstRange.upperBound.timeIntervalSinceReferenceDate - firstRange.lowerBound.timeIntervalSinceReferenceDate) / 2
let centerDateInSeconds = (TimeInterval(inValue.startAnchor.x) * (rangeInSeconds * 2)) + firstRange.lowerBound.timeIntervalSinceReferenceDate
let centerDate = Calendar.current.startOfDay(for: Date(timeIntervalSinceReferenceDate: centerDateInSeconds)).addingTimeInterval(43200)
let newRange = max(86400, (rangeInSeconds * 1.2) / inValue.magnification)
_data.dataWindowRange = (centerDate.addingTimeInterval(-newRange)...centerDate.addingTimeInterval(newRange)).clamped(to: _data.totalDateRange)
}
}
.onEnded { _ in _firstRange = nil }
Nota Bene
Note that we have explicitly provided axis values. This is important. If we relied on the adaptive axis values, provided by the chart, they would give us a lot of trouble, when we zoom in or out. The Y-axis would change, if we zoomed into a range that has lower values than the maximum, and the X-axis could move around, in uncomfortable ways.
The Final Result
Below, is the full code for the DemoChartDisplay.swift
file (minus the comments):
import SwiftUI import Charts struct DemoChartDisplay: View { @State private var _selectedValuesString: String = " " @State private var _selectedValue: DataProvider.Row? { didSet { if let selectedValue = _selectedValue, 1 < selectedValue.userTypes.count { _data.selectRow(selectedValue) let dateFormatter = DateFormatter() dateFormatter.dateStyle = .short dateFormatter.timeStyle = .none _selectedValuesString = String(format: "%@: Active: %d, New: %d, Total: %d", dateFormatter.string(from: selectedValue.sampleDate), selectedValue.userTypes[0].value, selectedValue.userTypes[1].value, selectedValue.userTypes[0].value + selectedValue.userTypes[1].value ) } else { _selectedValuesString = " " _data.deselectAllRows() } } } @State private var _firstRange: ClosedRange<date>? { didSet { if oldValue != _firstRange { _selectedValue = nil } } } @State private var _data = DataProvider() var body: some View { VStack { Text(_selectedValuesString) .font(.system(size: 14)) .foregroundStyle(_data.legend.last?.value ?? .yellow) Chart(_data.windowedRows) { inRow in ForEach(inRow.userTypes) { inUserType in BarMark( x: .value("Date", inRow.sampleDate, unit: .day), y: .value(inUserType.description, inUserType.value) ) .foregroundStyle(inUserType.color) } } .chartYAxis { AxisMarks(preset: .aligned, position: .leading, values: _data.yAxisCountValues()) { _ in AxisGridLine() AxisTick() AxisValueLabel(anchor: .trailing) } } .chartYAxisLabel("Users") .chartXAxis { AxisMarks(preset: .aligned, position: .bottom, values: _data.xAxisDateValues()) { inValue in if let dateString = inValue.as(Date.self)?.formatted(Date.FormatStyle().month(.abbreviated).day(.twoDigits)) { AxisGridLine() AxisTick(stroke: StrokeStyle()) AxisValueLabel(dateString, anchor: .top) } } } .chartXAxisLabel("Date", alignment: .center) .chartForegroundStyleScale(_data.legend) .chartOverlay { inChart in GeometryReader { inGeom in Rectangle() .fill(Color.clear) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { inValue in if let frame = inChart.plotFrame { guard let date = inChart.value(atX: max(0, min(inChart.plotSize.width, inValue.location.x - inGeom[frame].origin.x)), as: Date.self) else { return } _selectedValue = _data.windowedRows.nearestTo(date) } } .onEnded { _ in _selectedValue = nil } ) .gesture( MagnifyGesture() .onChanged { inValue in _firstRange = _firstRange ?? _data.dataWindowRange if let firstRange = _firstRange { let rangeInSeconds = (firstRange.upperBound.timeIntervalSinceReferenceDate - firstRange.lowerBound.timeIntervalSinceReferenceDate) / 2 let centerDateInSeconds = (TimeInterval(inValue.startAnchor.x) * (rangeInSeconds * 2)) + firstRange.lowerBound.timeIntervalSinceReferenceDate let centerDate = Calendar.current.startOfDay(for: Date(timeIntervalSinceReferenceDate: centerDateInSeconds)).addingTimeInterval(43200) let newRange = max(86400, (rangeInSeconds * 1.2) / inValue.magnification) _data.dataWindowRange = (centerDate.addingTimeInterval(-newRange)...centerDate.addingTimeInterval(newRange)).clamped(to: _data.totalDateRange) } } .onEnded { _ in _firstRange = nil } ) } } } } }
Conclusion
We have now added two fairly simple gestures to a SwiftUI chart: A drag/select gesture, and a magnification gesture. Both work across the X-axis.
There’s still some more to do, before we’d consider this “shippable.” For instance, when we are zoomed in, we don’t easily know where we are, along the X-axis, so we should add some indicator/control (like a slider). Also, I’d like to be able to drag the selection off to either side, if we are zoomed in, to pan the data window.
Also, the pinch magnification gesture won’t work on the Mac (but the drag selection one will), and the app doesn’t have localization or accessibility set on its views.
I just wanted to keep the demonstration as simple as possible, so I deliberately avoided adding these. That’s not even mentioning things like localization and accessibility support.
The DataProvider
Struct
As stated above, most of the real action happens in the DataProvider
Structure utility. This is the app model, and it also manages the state that is generated by the gestures. It is referenced inside the chart as a @State
property, so changes to it, automatically trigger redraws of the chart. The two gestures affect this type.