SwiftUI 4 & iOS 16 introduced native Charts Framework for Apple ecosystem. Building charts and graphs is easier than ever with the new API. We have explored charts framework in detail in the past; a video course on that can be found at the link below
In this article, we will explore chart interaction using chartOverlay modifier.
Charts plotted using the new Charts Framework are great for visualizing data, but they are not interactive. It would be great if we could add some interaction to charts and this is where the chartOverlay modifier comes into picture.
chartOverlay adds an overlay to a view that contains a chart and we can use this modifier to define an overlay view as a function of the chart in the view. We can access the chart with the ChartProxy object passed into the closure.
Let’s start by plotting a simple bar chart. We will define a data structure to use with the chart.
struct Workout: Identifiable, Hashable {
var id = UUID()
var day: String
var minutes: Int
}
Let’s add some sample data in Workout extension.
extension Workout {
static var walkData: [Workout] {
[
.init(day: "Mon", minutes: 23),
.init(day: "Tue", minutes: 45),
.init(day: "Wed", minutes: 76),
.init(day: "Thu", minutes: 21),
.init(day: "Fri", minutes: 15),
.init(day: "Sat", minutes: 35),
.init(day: "Sun", minutes: 10)
]
}
}
Next we will add a charts view.
import Chartsstruct DevTechieChartOverlayExample: View {
@State private var data = Workout.walkData
var body: some View {
NavigationStack {
VStack {
Chart(data) {
BarMark(
x: .value("Day", $0.day),
y: .value("Minutes", $0.minutes)
)
}
.frame(height: 400)
}
.navigationTitle("DevTechie")
}
}
}
Build and run to see data plotted in a bar chart.
Let’s add a chartOverlay modifier to our chart view. We can use chartOverlay to add background color to our chart.
import Chartsstruct DevTechieChartOverlayExample: View {
@State private var data = Workout.walkData
var body: some View {
NavigationStack {
VStack {
Chart(data) {
BarMark(
x: .value("Day", $0.day),
y: .value("Minutes", $0.minutes)
)
}
.frame(height: 400)
.chartOverlay { pr in
RoundedRectangle(cornerRadius: 5)
.foregroundStyle(.orange.gradient.opacity(0.2))
}
.padding()
}
.navigationTitle("DevTechie")
}
}
}
We also get access to ChartProxy object in trailing closure for chartOverlay. ChartProxy is a proxy that gives us access to the scales and plot area of a chart.
chartBackground is another modifier which gives us access to the ChartProxy
ChartProxy gives us chart’s plotAreaFrame information, which when combined with GeometryReader can help us compute the touch or drag location. So we will add a GeometryReader inside the chartOverlay.
struct DevTechieChartOverlayExample: View {
@State private var data = Workout.walkData
var body: some View {
NavigationStack {
VStack {
Chart(data) {
BarMark(
x: .value("Day", $0.day),
y: .value("Minutes", $0.minutes)
)
}
.frame(height: 400)
.chartOverlay { pr in
GeometryReader { geoProxy in
}
}
.padding()
}
.navigationTitle("DevTechie")
}
}
}
We want to show user’s drag location with a line, so let’s draw a rectangle with two-point width inside the Geometry Reader.
struct DevTechieChartOverlayExample: View {
@State private var data = Workout.walkData
var body: some View {
NavigationStack {
VStack {
Chart(data) {
BarMark(
x: .value("Day", $0.day),
y: .value("Minutes", $0.minutes)
)
}
.frame(height: 400)
.chartOverlay { pr in
GeometryReader { geoProxy in
Rectangle().foregroundStyle(Color.orange.gradient)
.frame(width: 2, height: geoProxy.size.height * 0.95)
}
}
.padding()
}
.navigationTitle("DevTechie")
}
}
}
Our rectangle will appear at the beginning of the chart but at this point the bar is always showing, we only want to show this when user has started dragging, so let’s add a state variable to control this rectangle’s opacity.
import Chartsstruct DevTechieChartOverlayExample: View {
@State private var data = Workout.walkData
@State private var showSelectionBar = false
var body: some View {
NavigationStack {
VStack {
Chart(data) {
BarMark(
x: .value("Day", $0.day),
y: .value("Minutes", $0.minutes)
)
}
.frame(height: 400)
.chartOverlay { pr in
GeometryReader { geoProxy in
Rectangle().foregroundStyle(Color.orange.gradient)
.frame(width: 2, height: geoProxy.size.height * 0.95)
.opacity(showSelectionBar ? 1.0 : 0.0)
}
}
.padding()
}
.navigationTitle("DevTechie")
}
}
}
Next, we will add a capsule, which will show selected days and workout minutes.
Let’s also add state variables to keep track of x and y offsets while user is dragging along with selected day and workout minutes. We will set showSelectionBar to true to test the view.
struct DevTechieChartOverlayExample: View {
@State private var data = Workout.walkData
@State private var showSelectionBar = true
@State private var offsetX = 0.0
@State private var offsetY = 0.0
@State private var selectedDay = ""
@State private var selectedMins = 0
var body: some View {
NavigationStack {
VStack {
Chart(data) {
BarMark(
x: .value("Day", $0.day),
y: .value("Minutes", $0.minutes)
)
}
.frame(height: 400)
.chartOverlay { pr in
GeometryReader { geoProxy in
Rectangle().foregroundStyle(Color.orange.gradient)
.frame(width: 2, height: geoProxy.size.height * 0.95)
.opacity(showSelectionBar ? 1.0 : 0.0)
Capsule()
.foregroundStyle(.orange.gradient)
.frame(width: 100, height: 50)
.overlay {
VStack {
Text("\(selectedDay)")
Text("\(selectedMins) mins")
.font(.title2)
}
.foregroundStyle(.white.gradient)
}
.opacity(showSelectionBar ? 1.0 : 0.0)
.offset(x: offsetX - 50, y: offsetY - 50)
}
}
.padding()
}
.navigationTitle("DevTechie")
}
}
}
Let’s set the showSelectionBar to false.
@State private var showSelectionBar = false
Next, we will add a Rectangle shape with clear color on which we will use to attach our drag gesture.
Rectangle().fill(.clear).contentShape(Rectangle()).gesture(DragGesture())
Our code will look like
struct DevTechieChartOverlayExample: View {
@State private var data = Workout.walkData
@State private var showSelectionBar = false
@State private var offsetX = 0.0
@State private var offsetY = 0.0
@State private var selectedDay = ""
@State private var selectedMins = 0
var body: some View {
NavigationStack {
VStack {
Chart(data) {
BarMark(
x: .value("Day", $0.day),
y: .value("Minutes", $0.minutes)
)
}
.frame(height: 400)
.chartOverlay { pr in
GeometryReader { geoProxy in
Rectangle().foregroundStyle(Color.orange.gradient)
.frame(width: 2, height: geoProxy.size.height * 0.95)
.opacity(showSelectionBar ? 1.0 : 0.0)
Capsule()
.foregroundStyle(.orange.gradient)
.frame(width: 100, height: 50)
.overlay {
VStack {
Text("\(selectedDay)")
Text("\(selectedMins) mins")
.font(.title2)
}
.foregroundStyle(.white.gradient)
}
.opacity(showSelectionBar ? 1.0 : 0.0)
.offset(x: offsetX - 50, y: offsetY - 50)
Rectangle().fill(.clear).contentShape(Rectangle())
.gesture(DragGesture())
}
}
.padding()
}
.navigationTitle("DevTechie")
}
}
}
DragGesture’s onChange function will give us access to the drag location which we will use to convert offset location.
ChartProxy provides us value (at: as:) function which returns the data values at the given x and y positions. It returns nil if the position does not correspond to a valid y value.
We will use a combination of GeometryProxy and ChartProxy to compute user’s drag location and convert that location information into workout data.
We will also set offset values so we can move our rectangle and capsule along with the drag.
import Chartsstruct DevTechieChartOverlayExample: View {
@State private var data = Workout.walkData
@State private var showSelectionBar = false
@State private var offsetX = 0.0
@State private var offsetY = 0.0
@State private var selectedDay = ""
@State private var selectedMins = 0
var body: some View {
NavigationStack {
VStack {
Chart(data) {
BarMark(
x: .value("Day", $0.day),
y: .value("Minutes", $0.minutes)
)
}
.frame(height: 400)
.chartOverlay { pr in
GeometryReader { geoProxy in
Rectangle().foregroundStyle(Color.orange.gradient)
.frame(width: 2, height: geoProxy.size.height * 0.95)
.opacity(showSelectionBar ? 1.0 : 0.0)
Capsule()
.foregroundStyle(.orange.gradient)
.frame(width: 100, height: 50)
.overlay {
VStack {
Text("\(selectedDay)")
Text("\(selectedMins) mins")
.font(.title2)
}
.foregroundStyle(.white.gradient)
}
.opacity(showSelectionBar ? 1.0 : 0.0)
.offset(x: offsetX - 50, y: offsetY - 50)
Rectangle().fill(.clear).contentShape(Rectangle())
.gesture(DragGesture().onChanged { value in
if !showSelectionBar {
showSelectionBar = true
}
let origin = geoProxy[pr.plotAreaFrame].origin
let location = CGPoint(
x: value.location.x - origin.x,
y: value.location.y - origin.y
)
offsetX = location.x
offsetY = location.y
let (day, _) = pr.value(at: location, as: (String, Int).self) ?? ("-", 0)
let mins = Workout.walkData.first { w in
w.day.lowercased() == day.lowercased()
}?.minutes ?? 0
selectedDay = day
selectedMins = mins
}
.onEnded({ _ in
showSelectionBar = false
}))
}
}
.padding()
}
.navigationTitle("DevTechie")
}
}
}
Notice the issue, our capsule view is moving but the rectangle is not. Its easy to fix, just add an offset modifier to the rectangle. Since we only want to move a rectangle on the x-axis only, we will only set offset for x-value.
struct DevTechieChartOverlayExample: View {
@State private var data = Workout.walkData
@State private var showSelectionBar = false
@State private var offsetX = 0.0
@State private var offsetY = 0.0
@State private var selectedDay = ""
@State private var selectedMins = 0
var body: some View {
NavigationStack {
VStack {
Chart(data) {
BarMark(
x: .value("Day", $0.day),
y: .value("Minutes", $0.minutes)
)
}
.frame(height: 400)
.chartOverlay { pr in
GeometryReader { geoProxy in
Rectangle().foregroundStyle(Color.orange.gradient)
.frame(width: 2, height: geoProxy.size.height * 0.95)
.opacity(showSelectionBar ? 1.0 : 0.0)
.offset(x: offsetX)
Capsule()
.foregroundStyle(.orange.gradient)
.frame(width: 100, height: 50)
.overlay {
VStack {
Text("\(selectedDay)")
Text("\(selectedMins) mins")
.font(.title2)
}
.foregroundStyle(.white.gradient)
}
.opacity(showSelectionBar ? 1.0 : 0.0)
.offset(x: offsetX - 50, y: offsetY - 50)
Rectangle().fill(.clear).contentShape(Rectangle())
.gesture(DragGesture().onChanged { value in
if !showSelectionBar {
showSelectionBar = true
}
let origin = geoProxy[pr.plotAreaFrame].origin
let location = CGPoint(
x: value.location.x - origin.x,
y: value.location.y - origin.y
)
offsetX = location.x
offsetY = location.y
let (day, _) = pr.value(at: location, as: (String, Int).self) ?? ("-", 0)
let mins = Workout.walkData.first { w in
w.day.lowercased() == day.lowercased()
}?.minutes ?? 0
selectedDay = day
selectedMins = mins
}
.onEnded({ _ in
showSelectionBar = false
}))
}
}
.padding()
}
.navigationTitle("DevTechie")
}
}
}