SwiftUI Gestures Logo

Adding A Selection Gesture

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

We now have a nice bar chart. It shows the entire range of our dataset, sizes itself accordingly, shows the difference between the new users, and the active users, has the Y-Axis labels on the left (leading) edge of the chart, and has the X-Axis values displayed from the start to the finish, in discrete steps.

We want to add the ability to touch the chart, have the bar under the touch, become “selected,” and display its values, in a text item.

We want the selection to follow the touch, and disappear, once the touch ends.

We Already Have Some Support

If you noticed, the legend, below the chart, has a color that doesn’t appear in the chart; “Selected”:

Figure 2: The Selected Legend Item

That’s the color that we’ll use for the bar that’s currently selected, so there’ll be a red bar, between green/blue ones.

We will also want to display a text item (label), summarizing the values in the selected bar, so that selecting a day, will report the exact numbers for that day.

Step One: Add A Text Property

So, the first thing that we’ll do, is add a text property to the chart display view, to hold the string that we’ll be showing the user, when they select a day:

struct DemoChartDisplay: View {
    @State private var _selectedValuesString: String = " "

    @State private var _data = DataProvider()

The assignment of a space character, is because it gives the property an initial value, and also, because we want the text item that relies on this property to always have a string, so it doesn’t “collapse,” making the chart “jump,” when selection begins and ends.

Step Two: Add A Text Item

Now that we have a text property, we need to add the ability to display it.

We’ll wrap the chart in a VStack, so that the new text item and the chart play well, together, and add a Text view, referencing our property, under it. We want it to be a single-line string, in the same color as the selection:

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)
            }
        }

Now, whenever the _selectedValuesString value changes, the new Text view will display the new string.

The selected bar will already draw red, if we tell the DataProvider instance that the row is selected. The foregroundStyle adornment takes care of that:

.foregroundStyle(inUserType.color)

Step 3: Add A Selection State Property

The last thing that we need to do, before we add the gesture, itself, is to give it a state property to affect.

We will add an optional state property that holds a DataProvider.Row reference. Changing this will mark the row as selected (or not selected), in the DataProvider, and will also set the string into the _selectedValuesString property:

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 _data = DataProvider()

We’ll set this to the selected row, once we choose that, from our gesture. If we set it to a row, it will select the row in the model, and will also create a display string for our text item. If we end the selection, we simply set it to nil, and the selection is cleared from the model, and the string is reset to a space.

Step 4: Add A Drag Gesture

Now, we’re ready to add the interaction.

As in everything else SwiftUI, we do this by adding adornments to views.

What we’ll do, is add a chartOverlay, to manage the gesture:

.chartForegroundStyleScale(_data.legend)
.chartOverlay { inChart in
    ·
    ·
    ·
}              

In fact, the Apple documentation for chartOverlay has pretty much exactly what we’ll be doing next:

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

Here’s what’s going on:

Add A Geometry Reader

The first thing we add into the overlay content builder, is a GeometryReader. This gives us the ability to query the container, for its dimensions:

GeometryReader { inGeom in
    ·
    ·
    ·
}

Inside the GeometryReader‘s content builder, we add a Rectangle View:

Rectangle().fill(Color.clear).contentShape(Rectangle())

We fill it with clear, so that we can see the chart, underneath it, and then specify that its content shape be a Rectangle (I know, but that’s the way it is. We can have non-rectangular content, inside a Rectangle instance).

Add A Gesture Container

We attach a gesture container to the overlay, like so:

.gesture(
    ·
    ·
    ·
)

Add A Drag Gesture

This gives us a context and attachment for the gesture instance, itself:

.gesture(
    DragGesture(minimumDistance: 0)

We use a DragGesture, with a minimumDistance of 0, which makes it a tap/drag gesture (We get an onChanged call from the initial tap).

Attach A Gesture onChanged Closure

The actual selection action happens in the onChanged closure:

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)
        }
    }

What’s going on here, is that the gesture’s value is sent into the closure, in inValue, which contains the gesture’s location property. By default, this is in the local position context of the overlay, so we offset the location.x from that, to determine how far along the X-axis of the graph we are.

We then clip this x value, so that it won’t extend beyond either edge of the chart, and query the chart object (using .value(atX:as:)), to get the date that corresponds to the X-axis value.

At this point, we have an arbitrary date, that might not match any of our stored rows, so we’ll need to get the row closest to that date. We do this, by using [DataProvider.Row].nearestTo(). That returns a reference that points to the Row instance representing the values nearest the touch.

At this point, all we need to do, is assign that reference to the _selectedValue state property, and the chart row will display as highlighted, and the text item over the chart will display the value.

Attach A Gesture onEnded Closure

This closure is called, when the drag has ended, and the user has lifted their finger. We simply set _selectedValue to nil, which deselects everything, and sets a single space into the text item.

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 }

And We’re Done (For Now)

Now, if you run the app, and touch the chart, it will display a red row, to indicate which one is selected, and the text item above the chart will show the value for the selected row. Scrubbing across the chart, continuously updates the value, and lifting your finger, stops it:

Figure 3: The Selection In Action

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 _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 }
                        )
                }
            }
        }
    }
}

The relevant tag in the repo, is 01.Drag-To-Select.

Onward And Upward

Now that we have dynamic selection sorted, let’s move on to pinch-to-zoom.