Skip to Content
Course content

219: Interactive Charts with Gestures

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first start making your Swift Charts interactive, your instinct is probably to treat the Chart view like any other SwiftUI view. You'll think, "I'll just slap a DragGesture on the whole thing, track the location of the finger, and map that X-coordinate to my data array." It sounds simple enough, and for a very basic bar chart with five bars, it might even work.

The trap of coordinate guesswork

The problem is that a Chart isn't just a rectangle of pixels; it's a complex layout of axes, labels, padding, and plot areas. If you try to calculate the selected data point by taking the gesture's location.x and dividing it by the total width of the view, you're going to be off. Why? Because your calculation includes the Y-axis labels on the left and the padding on the right. Your "selected" point will drift further and further away from the actual data point as the user moves their finger.

// The naive approach: Guessing based on view width
Chart(heartRateData) { point in
    LineMark(
        x: .value("Time", point.time),
        y: .value("BPM", point.bpm)
    )
}
.gesture(
    DragGesture().onChanged { value in
        // This is where it breaks. 
        // 'value.location.x' includes the axis labels!
        let percent = value.location.x / totalWidth 
        let index = Int(percent * Double(heartRateData.count))
        self.selectedPoint = heartRateData[index]
    }
)


I've seen this bug pop up in countless PRs. The developer tests it on an iPhone 15, it looks "mostly okay," then they run it on an iPad or a smaller SE, and suddenly the tooltip is hovering three points to the left of where the finger actually is. It's fragile because it assumes the plot area is the same size as the view area.

Letting ChartProxy do the heavy lifting

The professional way to handle this is by using ChartProxy. Instead of guessing where the finger is relative to the screen, you ask the chart itself to translate that screen coordinate back into the data values used by the axes. This is the "secret sauce" for building those polished, scrubbable charts you see in the Apple Health app.

To do this, you use a chartOverlay. This gives you access to a ChartProxy object, which has a method called value(atX:). This method does the hard math for you: it subtracts the padding, accounts for the axis labels, and returns the actual value of the X-axis at that specific pixel location.

Chart(heartRateData) { point in
    LineMark(
        x: .value("Time", point.time),
        y: .value("BPM", point.bpm)
    )
}
.chartOverlay { proxy in
    GeometryReader { geometry in
        Rectangle().fill(.clear).contentShape(Rectangle())
            .gesture(
                DragGesture().onChanged { value in
                    // Convert the touch point to a data value
                    if let date: Date = proxy.value(atX: value.location.x) {
                        // Now we find the data point closest to that date
                        self.selectedPoint = heartRateData.min(by: {
                            abs($0.time.timeIntervalSince(date)) < abs($1.time.timeIntervalSince(date))
                        })
                    }
                }
            )
    }
}


Balancing precision and performance

Now, you might notice that proxy.value(atX:) returns the exact value on the axis—for example, 10:04:22 AM—but your data might only have samples every minute. This is why I used min(by:) in the example above. We aren't looking for an exact match (which almost never happens with Date or Double); we're looking for the closest sample.

One last tip: notice the Rectangle().fill(.clear).contentShape(Rectangle()). If you just put a gesture on a GeometryReader or a transparent overlay, SwiftUI sometimes ignores touches in the "empty" spaces. By adding a clear rectangle with a defined content shape, you ensure the entire chart area is hit-testable, making the scrubbing feel fluid rather than glitchy.




📋 Practical Task

Exercise: Building a Heart Rate Scrubbing Tool

Your task is to create a heart rate monitoring view that allows a user to scrub through a 24-hour data set to see the exact BPM at any given time.

  • Create a HeartRateSample struct with time: Date and bpm: Int.
  • Generate a mock array of 50 samples spanning a single day.
  • Implement a Chart using a LineMark to visualize the heart rate.
  • Use a chartOverlay and ChartProxy to detect a DragGesture.
  • As the user drags, update a selectedSample state variable to the sample closest to the touch location.
  • Display the selectedSample.bpm and selectedSample.time in a text label above the chart, but only when a sample is actually selected.
  • Bonus: Add a RuleMark that moves horizontally across the chart to follow the user's finger.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.