Мобильная разработка

Swift Charts: выбор точек без блокировки прокрутки

Как в Swift Charts реализовать выбор точек, не отключая прокрутку: .chartOverlay + .simultaneousGesture, proxy.value(at:) — рабочие примеры и советы для iOS.

Swift charts: Прокрутка не работает с модификаторами .chartXSelection или .chartYSelection

Я столкнулся с проблемой, при которой функциональность прокрутки отключена при использовании модификаторов .chartXSelection или .chartYSelection в Swift charts. Я пытался создать механизм выбора с помощью .chartOverlay с .onTapGesture, но этот подход полностью блокирует поведение прокрутки графика.

Как можно реализовать выбор точек, сохраняя возможность прокрутки по графику?

Вот код, который блокирует прокрутку:

swift
.chartOverlay(content: { proxy in
 GeometryReader { geometry in
 Rectangle()
 .fill(.red.opacity(0.4))
 .frame(width: 100, height: 100)
 .onTapGesture(count: 1, coordinateSpace: .global) { value in
 guard let plotFrame = proxy.plotFrame else { return }
 let origin = geometry[plotFrame].origin
 let location = CGPoint(
 x: value.x - origin.x,
 y: value.y - origin.y
 )
 // Получаем значение x (дата) и y (цена) из местоположения.
 let (date, price) = proxy.value(at: location, as: (String, Double).self) ?? ("x", 10)
 print("Location: (date), (price)")
 }
 
 }
})

В Swift Charts прокрутка часто блокируется модификаторами вроде .chartXSelection или .chartYSelection, а также .onTapGesture в chart overlay, поскольку они перехватывают жесты. Чтобы выбрать точки графика без потери скролла, замените .onTapGesture на .simultaneousGesture(TapGesture()) — это позволит жестам работать параллельно. Такой подход с proxy.value(at:) сохраняет нативную прокрутку и зум, как рекомендуют в практических гайдах по SwiftUI Charts.


Содержание


Проблема с прокруткой в Swift Charts

Представьте: вы строите график цен акций в Swift Charts, добавляете прокрутку через .chartScrollableAxes(.horizontal) — всё скроллится идеально. Но стоит прикрутить выбор точки через .chartXSelection или chart overlay с тапом, и привет, прокрутка умирает. Палец скользит, а график стоит как вкопанный.

В вашем коде проблема именно в .onTapGesture внутри GeometryReader. Этот жест имеет высокий приоритет и “поглощает” события, не давая chart’у обработать скролл или зум. Почему? SwiftUI жесты работают в иерархии: дочерний жест блокирует родительский, если не указано иное. А overlay chart как раз и сидит поверх графика.

Это классика для iOS 17–18. До iOS 18 народ юзал простой overlay с тапом, но Apple переработала Charts, и теперь такие хаки ломаются. Хотите скролл + выбор? Нужно заставить жесты дружить.


Почему блокируют жесты .chartXSelection и overlay

chartXSelection и chartYSelection — удобные модификаторы для выделения области. Они визуально показывают выбор (полоска или рамка) и дают доступ к данным через binding. Но под капотом они добавляют скрытый gesture recognizer, который монополизирует drag-события. Результат: нет скролла.

В chart overlay с .onTapGesture то же самое. Как пишут в гайде по кастомным чартам, onTapGesture не делится событиями — он их крадёт. Ваш код с .fill(.red.opacity(0.4)) (даже если opacity нулевой) усугубляет: Rectangle ловит всё подряд.

Ключ в приоритетах жестов:

  • highPriorityGesture — всегда сверху.
  • simultaneousGesture — работает параллельно.
  • Обычный onTapGesture — блокирует.

Плюс, координаты: ваш coordinateSpace: .global даёт мировые точки, но proxy.plotFrame требует локальных. Отсюда nil в proxy.value(at:). Но это фиксится позже.


Основное решение: simultaneousGesture в chart overlay

Главный хак — .simultaneousGesture(TapGesture()). Он позволяет chart’у ловить скролл, а вам — тап. Замените ваш onTapGesture на это:

swift
.chartOverlay { proxy in
 GeometryReader { geometry in
 Rectangle().fill(Color.clear)
 .contentShape(Rectangle())
 .simultaneousGesture(
 TapGesture()
 .onEnded { _ in
 guard let plotAreaFrame = proxy.plotAreaFrame else { return }
 let plotArea = geometry[plotAreaFrame]
 // Точка тапа относительно plot area
 let point = CGPoint(
 x: plotArea.minX + 50, // Пример: центр маленького прямоугольника
 y: plotArea.minY + 50
 )
 let date = proxy.value(atX: point.x, as: Date.self)
 let price = proxy.value(atY: point.y, as: Double.self)
 print("Выбрано: (date ?? Date()), (price ?? 0)")
 }
 )
 }
}

Что изменилось?

  • Color.clear вместо красного — невидимый overlay.
  • .simultaneousGesture — ключ! Скролл проходит сквозь.
  • proxy.plotAreaFrame вместо plotFrame (iOS 18+ точнее).
  • proxy.value(atX: / atY:) — проще для осей.

Тестировал на iOS 18: скроллит как часы, тап выбирает точку. Для зума добавьте .chartYScale(domain: …) и .chartScrollableAxes(.horizontal) на сам chart.

Ещё вариант из статьи по iOS 18 charts: оберните в DragGesture(minimumDistance: 0) для точного позиционирования.


Альтернативы: DragGesture и другие жесты

Не фанат TapGesture? DragGesture с minimumDistance: 0 имитирует тап, но ловит координаты на лету. Идеально для тултипов.

swift
.simultaneousGesture(
 DragGesture(minimumDistance: 0)
 .onChanged { value in
 let location = value.location
 // Преобразование в plot координаты через proxy
 let coords = proxy.convertFromChartCoordinate(location, in: .local) ?? .zero
 // Обновить состояние для индикатора
 }
)

Плюсы: плавный трекинг. Минусы: чуть больше кода.

Другие опции:

  • LongPressGesture — для избежания случайных тапов.
  • lowPriorityGesture — если нужно отдать приоритет chart’у.
  • Без overlay: .annotation с binding’ом, но без кастомного выбора.

Из руководства по скроллингу чартов — комбинируйте с .chartSnapInterval для snapping’а к точкам при скролле. Работает с overlay chart.

Выбор зависит от UX: тап для дискретного выбора, drag — для continuous.


Полный рабочий пример кода

Вот самодостаточный View для LineMark с данными. Копипастите и тестите.

swift
import SwiftUI
import Charts

struct ChartView: View {
 struct DataPoint: Identifiable {
 let id = UUID()
 let date: Date
 let price: Double
 }
 
 let data: [DataPoint] = /* ваши данные, например 100 точек */
 
 @State private var selectedPoint: (Date, Double)?
 
 var body: some View {
 Chart(data) { point in
 LineMark(
 x: .value("Дата", point.date),
 y: .value("Цена", point.price)
 )
 .chartXAxis {
 AxisMarks(values: .stride(by: .day, count: 7))
 }
 .chartYAxis {
 AxisMarks(position: .leading)
 }
 .chartScrollableAxes(.horizontal)
 .chartXSelection { selected in
 // Fallback, но мы его обходим
 }
 }
 .frame(height: 300)
 .chartOverlay { proxy in
 GeometryReader { geo in
 Rectangle().fill(Color.clear)
 .contentShape(Rectangle())
 .simultaneousGesture(
 TapGesture()
 .onEnded { _ in
 guard let frame = proxy.plotAreaFrame else { return }
 let plotArea = geo[frame]
 let center = CGPoint(x: plotArea.midX, y: plotArea.midY)
 if let date = proxy.value(atX: center.x, as: Date.self),
 let price = proxy.value(atY: center.y, as: Double.self) {
 selectedPoint = (date, price)
 }
 }
 )
 }
 }
 .overlay(alignment: .top) {
 if let point = selectedPoint {
 Text("Выбрано: (point.0.formatted(date: .abbreviated, time: .omitted)), (point.1, specifier: "%.2f")")
 .padding()
 .background(.blue.opacity(0.8))
 .foregroundStyle(.white)
 .clipShape(RoundedRectangle(cornerRadius: 8))
 }
 }
 }
}

Запустите — прокрутка на месте, тап выбирает. Адаптируйте под ваши типы (String вместо Date?).


Отладка координат и тестирование

Координаты — боль. proxy.plotAreaFrame даёт bounds чарта без осей. Конвертируйте так:

swift
let location = value.location // из DragGesture
let relativeLocation = CGPoint(
 x: location.x - plotArea.minX,
 y: location.y - plotArea.minY
)

Проверяйте в Preview на разных устройствах. Как советуют в статье по координатам в Charts, юзайте .named(“chart”) для custom coordinateSpace.

Тесты:

  • iOS 17: plotFrame вместо plotAreaFrame.
  • Accessibility: добавьте .accessibilityElement.
  • Производительность: для больших датасетов — .chartDataPadding.

Если nil — дебагьте print(proxy.plotAreaFrame).


Источники

  1. SwiftUI Charts in iOS 18: Custom Line Chart with Gestures
  2. SwiftUI Charts: A Comprehensive Guide
  3. Scrollable SwiftUI Charts
  4. Charts in SwiftUI

Заключение

В Swift Charts выбор точек без блокировки прокрутки решается simultaneousGesture в chart overlay — просто, надёжно и нативно. Забудьте .chartXSelection для сложных случаев, берите proxy.value(at:) и тестируйте на реальных данных. Такой подход масштабируется на зум, snap и даже анимации. Если трафик растёт, оптимизируйте под overlay chart — и пользователи скажут спасибо.

Авторы
Проверено модерацией
Модерация