Reading time: 4 min

This recipe shows how to add a Scan Line / Lollipop to a Chart to SwiftUI using Apple's new Charts Framework. It allows you to read data from the chart depending on its position using tap and drag gestures. The end result looks like this:

preview

Charts framework is available starting in SwiftUI 4 (iOS 16, macOS 12.4).

TL;DR You can find the full code in this gist.

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: 1).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 ScanLineTest {
  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 intake:

@State var selectedElement: FoodIntake?

var body: some View {
// ... same as before ...

Then, add the chartOverlay modifier to your chart:

Chart {
  // .. same as before ...
}
.chartOverlay { proxy in
  GeometryReader { geo in
    Rectangle()
      .fill(.clear)
      .contentShape(Rectangle())
      .gesture(
        SpatialTapGesture()
          .onEnded { value in
            let element = findElement(location: value.location, 
                                      proxy: proxy, 
                                      geometry: geo)
            if selectedElement?.date == element?.date {
              // If tapping the same element, clear the selection.
              selectedElement = nil
            } else {
              selectedElement = element
            }
          }
          .exclusively(before: DragGesture()
            .onChanged { value in
              selectedElement = findElement(location: value.location, 
                                            proxy: proxy,
                                            geometry: geo)
            })
      )
  }
}

The findElement method looks like this:

func findElement(location: CGPoint, 
                 proxy: ChartProxy, 
                 geometry: GeometryProxy) -> FoodIntake? {
  // Figure out the X position by offseting gesture location with chart frame
  let relativeXPosition = location.x - geometry[proxy.plotAreaFrame].origin.x
  // Use value(atX:) to find plotted value for the given X axis position.
  // Since FoodIntake chart plots `date` on the X axis, we'll get a Date back.
  if let date = proxy.value(atX: relativeXPosition) as Date? {
    // Find the closest date element.
    var minDistance: TimeInterval = .infinity
    var index: Int? = nil
    for dataIndex in intake.indices {
      let nthDataDistance = intake[dataIndex].date.distance(to: date)
      if abs(nthDataDistance) < minDistance {
        minDistance = abs(nthDataDistance)
        index = dataIndex
      }
    }
    if let index {
      return intake[index]
    }
  }
  return nil
}
Drawing scan line overlay

The last step is to actually draw the scan line and the selected value indicator (which are, combined, known as a lollipop). This action is essentially reverse from the previous step - here, we need to map selected data to chart position. Again, we can use ChartProxy for this, as its position(forX:) method does exactly what we want. Also, the chartBackground modifier exposes a ChartProxy and allows us to place views behind the chart, so that they don't interfere with chart gestures:

Chart {
  // ... same as before ...
}
.chartOverlay { proxy in
  // ... same as before ...
}
.chartBackground { proxy in
  ZStack(alignment: .topLeading) {
    GeometryReader { geo in
      if let selectedElement {
        // Find date span for the selected interval
        let dateInterval = Calendar.current.dateInterval(of: .day, for: selectedElement.date)!
        // Map date to chart X position
        let startPositionX = proxy.position(forX: dateInterval.start) ?? 0
        // Offset the chart X position by chart frame
        let midStartPositionX = startPositionX + geo[proxy.plotAreaFrame].origin.x
        let lineHeight = geo[proxy.plotAreaFrame].maxY
        let boxWidth: CGFloat = 150
        let boxOffset = max(0, min(geo.size.width - boxWidth, midStartPositionX - boxWidth / 2))

        // Draw the scan line
        Rectangle()
          .fill(.quaternary)
          .frame(width: 2, height: lineHeight)
          .position(x: midStartPositionX, y: lineHeight / 2)

        // Draw the data info box
        VStack(alignment: .leading) {
          Text("\(selectedElement.date, format: .dateTime.year().month().day())")
            .font(.callout)
            .foregroundStyle(.secondary)
          Text("\(selectedElement.calories, format: .number) calories")
            .font(.title2.bold())
            .foregroundColor(.primary)
        }
        .frame(width: boxWidth, alignment: .leading)
        .background { // some styling
          ZStack {
            RoundedRectangle(cornerRadius: 8)
              .fill(.background)
            RoundedRectangle(cornerRadius: 8)
              .fill(.quaternary.opacity(0.7))
          }
          .padding([.leading, .trailing], -8)
          .padding([.top, .bottom], -4)
        }
        .offset(x: boxOffset)
      }
    }
  }
}
.frame(height: 250)
.padding()

And that's it! You can now easily scan your chart and display selected data. Note that this method works regardless of chart type, i.e it can be used with scatter plot chart as well!

Next Post Previous Post