Search code examples
swiftuichartsscrollable

When scrolling a chart, how do I find the x-values of the data points in view?


I have an app with dates along the x-axis. The user can select a date range for which data will be displayed. This date range can be moved along the x-axis by inputting a date for the date range. By knowing the position of the date range, I can adjust the y-axis position and resolution so that all data points within the range are in view.

Now I want to achieve the same functionality when scrolling the chart. This is my code so far:

Chart {
    ...
}
.chartScrollableAxes(.horizontal)
.chartScrollPosition(initialX: today)
.chartXVisibleDomain(length: range)

Scrolling works fine, but data points move in and out of view. If I knew which data points lie within the date range (chartXVisibleDomain), I could adjust the y-axis accordingly.

This is the reason for my question. Or are there other view modifiers that could accomplish this?


Solution

  • You know the length of the x axis (as you used .chartXVisibleDomain(length: range)). You can also know the current scroll position using chartScrollPosition(x:), so the visible range of X values is just all the values from scroll position to (scroll position + range).

    And once you know the range of visible X values, you can use that to filter your data.

    Here is an example:

    struct ChartData: Identifiable {
        var id: Date { date }
        let date: Date
        let y: Double
    }
    
    let data = (1..<20).map { i in ChartData(
        date: Calendar.current.startOfDay(for: .now).addingTimeInterval(86400 * Double(i)),
        y: Double(i)
    ) }
    
    struct ContentView: View {
        @State var scrollPos = Date()
        var body: some View {
            VStack {
                Chart(data) { p in
                    BarMark(x: .value("X", p.date, unit: .day), y: .value("Y", p.y))
                }
                .chartScrollPosition(x: $scrollPos)
                // two days visible at a time
                .chartXVisibleDomain(length: 86400 * 2)
                .chartScrollableAxes(.horizontal)
                .containerRelativeFrame(.vertical, count: 2, spacing: 0)
                Text(scrollPos, format: .dateTime)
    
                // a list to show the visible Y values
                List {
                    let visibleRange = scrollPos.addingTimeInterval(-86400)...scrollPos.addingTimeInterval(86400 * 2)
                    let visibleData = data.filter { visibleRange.contains($0.date) }
                    ForEach(visibleData) { p in Text(p.y, format: .number) }
                }
            }
            .padding()
        }
    }
    

    Note that I subtracted another day from the lower bound of the visible range, because a part of the bar is still visible even if you scroll past the x coordinate of the bar. This is probably not necessary if you are using a line chart.

    Another way is to "abuse" chartOverlay to get your hands on a ChartProxy, and get the x values through that. This allows you to get the max x value even without knowing the visible length of the x axis.

    @State var minX = Date()
    @State var maxX = Date()
    var body: some View {
        VStack {
            Chart(data) { p in
                BarMark(x: .value("X", p.date, unit: .day), y: .value("Y", p.y))
            }
            .chartXVisibleDomain(length: 86400 * 2)
            .chartScrollableAxes(.horizontal)
            .chartOverlay { chart in
                GeometryReader { geo in
                    if let anchor = chart.plotFrame {
                        let frame = geo[anchor]
                        if let minX = chart.value(atX: -frame.minX, as: Date.self),
                           let maxX = chart.value(atX: -frame.minX + geo.size.width, as: Date.self) {
                            Color.clear
                                .onChange(of: minX, initial: true) { _, newValue in
                                    self.minX = newValue.addingTimeInterval(-86400)
                                }
                                .onChange(of: maxX, initial: true) { _, newValue in
                                    self.maxX = newValue
                                }
                        }
                    }
                }
            }
            .containerRelativeFrame(.vertical, count: 2, spacing: 0)
            Text(minX, format: .dateTime)
            List {
                let visibleRange = minX...maxX
                let visibleData = data.filter { visibleRange.contains($0.date) }
                ForEach(visibleData) { p in Text(p.y, format: .number) }
            }
        }
        .padding()
    }