SwiftUI Gestures Logo

Adding Pinch-To-Zoom to the Chart

This entry is part 3 of 4 in the series SwiftUI Charts Gestures

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:

Figure 4: Pinch-toZoom Demo

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._firstRange 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 Dates, to seconds, do the calculation, then return the new range to Dates. 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.