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:

preview

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()
}

Next Post Previous Post