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”:
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:
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.