Drag to Select in SwiftUI Charts
Reading time: 3 min
This recipe shows how to implement drag / swipe selection in SwiftUI using Apple's new Charts Framework. It allows you to read data for a range of values from the chart.
The end result looks like this:
Charts framework is available starting in SwiftUI 4 (iOS 16, macOS 12.4).
If you're interested in a scan line that selects a single value as opposed to a range, check out this recipe.
Sample data & bar chart
Let's start off by creating a simple structure that represents how many calories we ate each day. This is the data we'll be plotting in a bar chart. We'll also create a month's worth of random sample data:
struct FoodIntake: Hashable {
let date: Date
let calories: Int
}
// Utility function to easily create dates
func date(year: Int, month: Int, day: Int) -> Date {
Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) ?? Date()
}
// Sample data
let intake = stride(from: 1, to: 31, by: 2).map { day in
FoodIntake(date: date(year: 2022, month: 5, day: day),
calories: Int.random(in: 1800...2200))
}
Then, let's plot that data using a bar chart:
struct DragToSelectTest {
var body: some View {
Chart {
ForEach(intake, id: \.self) { data in
BarMark(x: .value("Date", data.date),
y: .value("Calories", data.calories))
}
}
}
}
Reading selected data
The next step is to detect user taps and drags on the chart and map their positions to sample data. We can do this using the chartOverlay
modifier, which exposes a ChartProxy
instance. ChartProxy
can access the plot area of the chart, allowing us to access data values via X and Y coordinates of our gesture positions.
First, add a @State
property to track selected date range and a computed property that gets the selected intakes:
@State var selectedDates: (Date, Date)? = nil
var selectedData: [FoodIntake] {
if let selectedDates {
return intake.filter { $0.date >= selectedDates.0 && $0.date <= selectedDates.1 }
} else {
return []
}
}
Then, add the chartOverlay
modifier to your chart. In it, add a GeometryReader
that holds a transparent Rectangle
. The Rectangle
overlays the entire chart and intercepts touches using DragGesture
. Then, you can map touch positions to points in the chart and use ChartProxy
to map those to actual data.
Chart {
// ... same as before ...
}
.chartOverlay { proxy in
GeometryReader { geo in
Rectangle()
.fill(Color.clear)
.contentShape(Rectangle())
.gesture(DragGesture()
.onChanged { value in
// find start and end positions of the drag
let start = geo[proxy.plotAreaFrame].origin.x
let xStart = value.startLocation.x - start
let xCurrent = value.location.x - start
// map those positions to X-axis values in the chart
if let dateStart: Date = proxy.value(atX: xStart),
let dateCurrent: Date = proxy.value(atX: xCurrent) {
selectedDates = (dateStart, dateCurrent)
}
}
.onEnded { _ in
selectedDates = nil // cancel selection when touches end
})
}
}
You can also add a RectangleMark
to visually indicate the selected range:
Chart {
// ... same as before ...
if let (start, end) = selectedDates {
RectangleMark(xStart: .value("Start", start),
xEnd: .value("End", end))
.foregroundStyle(.gray.opacity(0.2))
}
}
.chartOverlay { proxy in
// ... same as before
}
Finally, add a few Text
labels to the body to display the selected data:
var body: some View {
VStack(alignment: .leading) {
Chart {
// ... same as before ...
}
.chartOverlay { proxy in
// ... same as before
}
.frame(height: 400) // it's generally a good idea to limit the chart height
if let (start, end) = selectedDates {
Text("Selection: \(start, format: .dateTime.month(.abbreviated).day()) - \(end, format: .dateTime.month(.abbreviated).day())")
Text("Total calories: \(selectedData.map { $0.calories }.reduce(0, +))")
}
Spacer()
}
.padding()
}