Swift Charts: выбор точек без блокировки прокрутки
Как в Swift Charts реализовать выбор точек, не отключая прокрутку: .chartOverlay + .simultaneousGesture, proxy.value(at:) — рабочие примеры и советы для iOS.
Swift charts: Прокрутка не работает с модификаторами .chartXSelection или .chartYSelection
Я столкнулся с проблемой, при которой функциональность прокрутки отключена при использовании модификаторов .chartXSelection или .chartYSelection в Swift charts. Я пытался создать механизм выбора с помощью .chartOverlay с .onTapGesture, но этот подход полностью блокирует поведение прокрутки графика.
Как можно реализовать выбор точек, сохраняя возможность прокрутки по графику?
Вот код, который блокирует прокрутку:
.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
- Почему блокируют жесты .chartXSelection и overlay
- Основное решение: simultaneousGesture в chart overlay
- Альтернативы: DragGesture и другие жесты
- Полный рабочий пример кода
- Отладка координат и тестирование
- Источники
- Заключение
Проблема с прокруткой в 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 на это:
.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 имитирует тап, но ловит координаты на лету. Идеально для тултипов.
.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 с данными. Копипастите и тестите.
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 чарта без осей. Конвертируйте так:
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).
Источники
- SwiftUI Charts in iOS 18: Custom Line Chart with Gestures
- SwiftUI Charts: A Comprehensive Guide
- Scrollable SwiftUI Charts
- Charts in SwiftUI
Заключение
В Swift Charts выбор точек без блокировки прокрутки решается simultaneousGesture в chart overlay — просто, надёжно и нативно. Забудьте .chartXSelection для сложных случаев, берите proxy.value(at:) и тестируйте на реальных данных. Такой подход масштабируется на зум, snap и даже анимации. Если трафик растёт, оптимизируйте под overlay chart — и пользователи скажут спасибо.