swiftuiswiftui-charts

How can we make the chartXAxis area register taps for chartXSelection in Swift Charts?


I'm curious, is there a way to tell the chartXSelection(value:) modifier to include the x-axis area when responding to user gestures? I know that starting a tap in the chart area and dragging down works, but I would like the user to just be able to tap on a value that they like in the x-axis, and have it registered by chartXSelection.

See the example code below (edited to include tall axisMarks to demonstrate a problem with one proposed solution):

private struct PlotPoint {
    let x: Double
    let y: Double
}

private struct PlotSeries: Identifiable {
    let id: Int
    let name: String
    let points: [PlotPoint]
}

private let data: [PlotSeries] = [
    PlotSeries(id: 0, name: "Series A", points: stride(from: 0.0, through: 1.0, by: 0.05).map { y in PlotPoint(x: y*y, y: y) }),
    PlotSeries(id: 1, name: "Series B", points: stride(from: 0.0, through: 1.0, by: 0.05).map { y in PlotPoint(x: y*y, y: y/2) })
]

struct AxisTap: View {
    @State private var selectedX: Double?
    var body: some View {
        VStack {
            Chart {
                if let selectedX {
                    BarMark(xStart: .value("x", selectedX), xEnd: .value("x", selectedX + 0.05))
                        .foregroundStyle(.yellow.opacity(0.5))
                }

                ForEach(data) { series in
                    let plot = LinePlot( series.points, x: .value("x", \.x), y: .value("y", \.y))
                    
                    plot
                        .symbol(by: .value("Series", series.name))
                        .foregroundStyle(by: .value("Series", series.name))
                }
            }
            .chartXAxis {
                AxisMarks() { value in
                    AxisGridLine()
                    AxisTick()
                    AxisValueLabel() {
                        if let axisValue = value.as(Double.self) {
                            VStack {
                                Text("|")
                                Text("v")
                                Text("\(axisValue.formatted())")
                            }
                        }
                    }
                }
            }
            .chartXSelection(value: $selectedX)
            .padding()
            Text("selectedX is: \(selectedX ?? 0.0)")
        }
    }
}

I would like the user to be able to tap on the "0.5" in the x-axis and get a result like shown below (with "selectedX is: ~0.5" below).

Chart with a value near 0.5 selected


Solution

  • A chartGesture would also cover the area where the x axis lies.

    .chartGesture { proxy in
        DragGesture(minimumDistance: 0)
            .onChanged { value in
                selectedX = proxy.value(atX: value.location.x)
            }
            .onEnded { _ in
                selectedX = nil
            }
    }
    

    Now you don't need .chartXSelection(value: $selectedX) anymore, consider changing selectedX to a @GestureState if you don't need to set it programmatically.

    @GestureState private var selectedX: Double?
    
    // ...
    
    .chartGesture { proxy in
        DragGesture(minimumDistance: 0)
            .updating($selectedX) { value, state, _ in
                print(value.location.x)
                state = proxy.value(atX: value.location.x)
            }
    }
    

    For a taller x axis, you can add a Rectangle just below the plotFrame rectangle for detecting gestures, in addition to chartGesture or chartXSelection.

    .chartOverlay { proxy in
        GeometryReader { geo in
            let frame = geo[proxy.plotFrame!]
            Rectangle()
                .fill(.clear)
                .frame(width: frame.width, height: 50) // choose a desired height here
                .contentShape(.rect)
                .gesture(
                    DragGesture(minimumDistance: 0)
                        .updating($selectedX) { value, state, _ in
                            state = proxy.value(atX: value.location.x)
                        }
                )
                .offset(x: frame.minX, y: frame.maxY)
        }
    }