Issue: Chart Scroll Not Working in iOS 18 with Chart Overlay Tap Gesture

Summary

I have a SwiftUI Chart that worked correctly in iOS 17, allowing both horizontal scrolling and tap gesture selection. However, in iOS 18, the same exact chart will not allow for both tap gestures and scrolling to work -- it's either we allow scrolling or we allow tap gestures but not both. We have tried everything to try to circumvent this issue but have had to resort to old methods of creating the chart. This is an issue that has negatively impacted our customers as well.

Again, the charts were working fine on iOS 17, but on iOS 18 the chart scroll + tap gesture capability is not working.

Expected Behavior (iOS 17)

Users can scroll horizontally through the chart.

Users can tap on data points to highlight them.

The selected data point updates when tapped.

Observed Behavior (iOS 18)

The chart no longer scrolls when chartOverlay with the Tap Gesture is applied.

Tap selection still works as expected.

Code Snippet

Below is the working implementation from iOS 17:

    private var iOS17ChartView: some View {
        Chart {
            RectangleMark(
                yStart: .value(String(firstLevelAlertBand), firstLevelAlertBand),
                yEnd: .value("100", 100)
            )
            .foregroundStyle(Theme.Colors.green.opacity(0.15))
            
            RectangleMark(
                yStart: .value(String(secondLevelAlertBand), secondLevelAlertBand),
                yEnd: .value(String(firstLevelAlertBand), firstLevelAlertBand)
            )
            .foregroundStyle(Theme.Colors.orange.opacity(0.15))
            
            RectangleMark(
                yStart: .value("0", 0),
                yEnd: .value(String(secondLevelAlertBand), secondLevelAlertBand)
            )
            .foregroundStyle(Theme.Colors.red.opacity(0.15))
            
            ForEach(telemetryData, id: \.timestamp) { entry in
                if let utcDate = dateFormatter.date(from: entry.timestamp) {
                    let localDate = convertToUserTimeZone(date: utcDate)
                    let tankLevel = entry.tankLevel ?? 0
                    
                    LineMark(
                        x: .value("Date", localDate),
                        y: .value("Tank Level", tankLevel)
                    )
                    .foregroundStyle(statusColor)
                    
                    AreaMark(
                        x: .value("Date", localDate),
                        y: .value("Tank Level", tankLevel)
                    )
                    .foregroundStyle(statusColor.opacity(0.50))
                    
                    PointMark(
                        x: .value("Date", localDate),
                        y: .value("Tank Level", tankLevel)
                    )
                    .foregroundStyle(selectedDataPoint?.date == localDate ? Theme.Colors.primaryColor : statusColor)
                    .symbolSize(selectedDataPoint?.date == localDate ? 120 : 80)
                    
                    
                    PointMark(
                        x: .value("Date", localDate),
                        y: .value("Tank Level", tankLevel)
                    )
                    //.foregroundStyle(.white).symbolSize(10)
                    .foregroundStyle(Theme.Colors.white(colorScheme: colorScheme))
                    .symbolSize(12)
                }
            }
        }
        .chartXScale(domain: (firstTimestamp ?? Date())...(latestTimestamp ?? Date()))
        .chartXVisibleDomain(length: visibleDomainSize)
        .chartScrollableAxes(.horizontal)
        .chartScrollPosition(x: $chartScrollPositionX)
        .chartXAxis {
            AxisMarks(values: .stride(by: xAxisStrideUnit, count: xAxisCount())) { value in
                if let utcDate = value.as(Date.self) {
                    let localDate = convertToUserTimeZone(date: utcDate)
                    let formatStyle = self.getFormatStyle(for: interval)
                    
                    AxisValueLabel {
                        Text(localDate, format: formatStyle)
                            .font(Theme.Fonts.poppinsRegularExtraSmall)
                            .foregroundStyle(Theme.Colors.black(colorScheme: colorScheme))
                    }
                    AxisTick()
                        .foregroundStyle(Theme.Colors.black(colorScheme: colorScheme).opacity(1))
                }
            }
        }
        .chartOverlay { proxy in
            GeometryReader { geometry in
                Rectangle().fill(Color.clear).contentShape(Rectangle())
                    .onTapGesture { location in
                        let xPosition = location.x - geometry[proxy.plotAreaFrame].origin.x
                        
                        // Use proxy to get the x-axis value at the tapped position
                        if let selectedDate: Date = proxy.value(atX: xPosition) {
                            
                            if let closestEntry = telemetryData.min(by: { abs(dateFormatter.date(from: $0.timestamp)!.timeIntervalSince1970 - selectedDate.timeIntervalSince1970) < abs(dateFormatter.date(from: $1.timestamp)!.timeIntervalSince1970 - selectedDate.timeIntervalSince1970) }) {
                                
                                selectedDataPoint = (convertToUserTimeZone(date: dateFormatter.date(from: closestEntry.timestamp)!), closestEntry.tankLevel ?? 0)
                                
                                if let dateXPos = proxy.position(forX: convertToUserTimeZone(date: dateFormatter.date(from: closestEntry.timestamp)!)),
                                   let tankLevelYPos = proxy.position(forY: closestEntry.tankLevel ?? 0) {
                                    
                                    // Offset the x-position based on the scroll position
                                    let adjustedXPos = dateXPos - proxy.position(forX: chartScrollPositionX)!
                                    
                                    withAnimation(.spring()) {
                                        selectedPointLocation = CGPoint(x: adjustedXPos, y: tankLevelYPos - 60)  // Offset popup above the point
                                        showPopup = true
                                    }
                                }
                            }
                        }
                    }
            }
            .onChange(of: chartScrollPositionX) { newValue in
                // Dynamically update the popup position when scroll changes
                if let selectedDataPoint = selectedDataPoint {
                    if let dateXPos = proxy.position(forX: selectedDataPoint.date) {
                        let adjustedXPos = dateXPos - proxy.position(forX: chartScrollPositionX)!
                        selectedPointLocation.x = adjustedXPos
                    }
                }
            }
        }
    }

Please help!

Nick

Issue: Chart Scroll Not Working in iOS 18 with Chart Overlay Tap Gesture
 
 
Q