Чтобы выбирать фигуры на холсте SwiftUI в Mac, считывайте координаты указателя в системе координат представления холста, а затем отменяйте преобразование, используемое при рисовании. При масштабировании и панорамировании вычтите смещение панорамирования и разделите результат на масштаб, прежде чем проверять фигуры.
В этом руководстве создаётся небольшой настольный холст с двумя условными прямоугольниками. Вы будете подсвечивать прямоугольник под указателем и выбирать прямоугольник по завершении жеста указателя, в том числе после изменения масштаба или панорамирования. Все данные рисования хранятся в памяти.
В какой системе координат следует обрабатывать события указателя?
Различайте три системы координат: систему, в которой хранятся фигуры, локальную систему координат представления холста и координаты окна, предоставляемые AppKit. Точка полезна только тогда, когда известно, к какой системе она относится.
onContinuousHover(coordinateSpace:perform:) в SwiftUI предоставляет координаты указателя в запрошенной системе координат; по умолчанию это .local. Модификатор передаёт .active(location), пока указатель находится внутри представления, и .ended, когда он выходит за его пределы. Добавьте его непосредственно к холсту, чтобы его локальная система координат совпадала с областью рисования. Документация Apple о непрерывном отслеживании наведения.
Именованная система координат полезна, если область взаимодействия намеренно принадлежит родительскому представлению. В таком случае и обработчики наведения, и обработчики жестов должны запрашивать одну и ту же именованную систему. Apple определяет .local, .global и .named(_:) как системы координат макета; имя системы координат не задаёт модель рисования приложения. Документация Apple о системах координат.
Это различие важно, потому что Canvas выполняет рисование в режиме немедленного отображения. Apple прямо указывает, что отдельные нарисованные элементы, включая символы, не получают собственных средств интерактивности. В этом случае следует обрабатывать взаимодействие на содержащем холст представлении и самостоятельно проверять попадание в модель. Документация Apple о Canvas.
Как отменить преобразование, использованное при рисовании?
Выберите явное соглашение для рисования. В этом примере координаты модели увеличиваются вправо и вниз. Масштаб увеличивает единицы модели, а панорамирование задаётся смещением в локальных точках холста. Прямое отображение и обратное преобразование выглядят так:
viewX = modelX * zoom + panX
viewY = modelY * zoom + panY
modelX = (viewX - panX) / zoom
modelY = (viewY - panY) / zoom
Эти уравнения следуют из выбранного отображения. Для рисования сместите графический контекст на величину панорамирования, а затем масштабируйте его. Apple указывает, что эти методы обновляют преобразование контекста и влияют на последующие операции рисования. Если изменить порядок, отображение изменится, поэтому обратное преобразование должно соответствовать точной последовательности рисования. Документация о переносе, документация о масштабировании.
Для наглядной проверки используйте масштаб 2, панорамирование (40, 30) и локальное положение указателя (180, 110). Вычитание смещения панорамирования даёт (140, 80); деление на масштаб даёт положение в модели (70, 40). При рисовании эта точка модели возвращается в (180, 110). Следовательно, точка должна попасть в прямоугольник, рамка которого в координатах модели содержит (70, 40).
Выполняйте это вычисление в одном общем типе. Рендерер и код взаимодействия должны использовать одно и то же отображение, а не поддерживать отдельные формулы смещения. В примере принимаются только конечные положительные значения масштаба: ноль нельзя обратить.
Создайте полноценный пример с наведением и выбором
Используйте проект приложения SwiftUI для macOS с целевой версией macOS 14 или новее. Эта версия поддерживает перегрузку CoordinateSpaceProtocol, используемую модификатором наведения. Добавьте следующий файл модели, а затем замените сгенерированное объявление приложения вторым файлом. В целевом объекте должно быть ровно одно объявление @main. Доступность и сигнатура API наведения.
Определите геометрию модели и её отображение
Создайте файл CanvasGeometry.swift со следующим полным содержимым. Рамки прямоугольников остаются в координатах модели на всём протяжении взаимодействия.
import SwiftUI
struct CanvasMapping {
let zoom: CGFloat
let pan: CGSize
init(zoom: CGFloat, pan: CGSize) {
precondition(zoom.isFinite && zoom > 0)
precondition(pan.width.isFinite && pan.height.isFinite)
self.zoom = zoom
self.pan = pan
}
func modelPoint(from viewPoint: CGPoint) -> CGPoint {
CGPoint(
x: (viewPoint.x - pan.width) / zoom,
y: (viewPoint.y - pan.height) / zoom
)
}
func apply(to context: inout GraphicsContext) {
context.translateBy(x: pan.width, y: pan.height)
context.scaleBy(x: zoom, y: zoom)
}
}
struct CanvasItem {
let id: Int
let frame: CGRect
}
Геометрия и отображение представлены структурами Swift. Семантика значений позволяет легко передавать текущие значения кода рисования и проверки попаданий, не создавая ещё один общий изменяемый объект. В руководстве по языку Swift структуры описаны как типы-значения. Структуры и классы.
Рисуйте и обрабатывайте взаимодействие в одной области просмотра
Создайте файл PointerCanvasApp.swift, заменив сгенерированное объявление приложения. Этот полный файл использует типы из CanvasGeometry.swift.
import SwiftUI
@main
struct PointerCanvasApp: App {
var body: some Scene {
WindowGroup {
PointerCanvasView()
}
}
}
struct PointerCanvasView: View {
private let items = [
CanvasItem(id: 1, frame: CGRect(
x: 40, y: 30, width: 120, height: 80)),
CanvasItem(id: 2, frame: CGRect(
x: 120, y: 70, width: 120, height: 80))
]
@State private var zoom: CGFloat = 2
@State private var pan = CGSize(width: 40, height: 30)
@State private var pointerInView: CGPoint?
@State private var selectedID: Int?
private var mapping: CanvasMapping {
CanvasMapping(zoom: zoom, pan: pan)
}
private func hit(at viewPoint: CGPoint) -> Int? {
let point = mapping.modelPoint(from: viewPoint)
return items.reversed().first {
$0.frame.contains(point)
}?.id
}
var body: some View {
let currentMapping = mapping
let hoveredID = pointerInView.flatMap { hit(at: $0) }
VStack(alignment: .leading, spacing: 12) {
HStack {
Button("Zoom out") {
zoom = max(0.5, zoom / 2)
}
Button("Zoom in") {
zoom = min(4, zoom * 2)
}
Button("Pan right") {
pan.width += 20
}
Button("Reset view") {
zoom = 2
pan = CGSize(width: 40, height: 30)
}
Text("Selected: \(selectedID.map { String($0) } ?? "none")")
}
Canvas { context, _ in
var drawing = context
currentMapping.apply(to: &drawing)
for item in items {
let path = Path(item.frame)
drawing.fill(
path,
with: .color(
item.id == hoveredID ? .orange : .blue
)
)
if item.id == selectedID {
drawing.stroke(
path,
with: .color(.black),
lineWidth: 2 / currentMapping.zoom
)
}
}
}
.background(Color.white)
.contentShape(Rectangle())
.onContinuousHover(coordinateSpace: .local) { phase in
switch phase {
case .active(let point):
pointerInView = point
case .ended:
pointerInView = nil
}
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded { value in
selectedID = hit(at: value.location)
}
)
.clipped()
}
.padding(16)
.frame(minWidth: 640, minHeight: 420)
}
}
Холст использует прямоугольную область взаимодействия, включая пустое пространство между фигурами. contentShape(Rectangle()) задаёт форму для проверки попадания в представление; отдельная проверка frame.contains определяет, какой условный объект находится под указателем. Документация Apple о форме содержимого.
Для выбора используется DragGesture с минимальным расстоянием, равным нулю, и координата location события завершения. Такое поведение выбирает прямоугольник под указателем в момент отпускания, даже если во время нажатия указатель перемещался. Это выбор по позиции отпускания, а не строгая проверка щелчка. Apple документирует порог расстояния жеста и настройку системы координат, а location определяет как текущую позицию события. DragGesture, DragGesture.Value.
Объекты рисуются в порядке массива, а проверяются в обратном порядке. Поэтому в области пересечения побеждает прямоугольник 2, что соответствует его визуальному расположению в этом примере. Щелчок по пустому месту намеренно снимает выделение. Рамки фигур и содержимое массива не меняются.
Представление владеет состоянием указателя и выделения, а рендерер считывает его. Apple рекомендует объявлять закрытое состояние в самом верхнем представлении, которому оно требуется, и передавать привязки, если дочерним представлениям нужен доступ на запись. Если позже родительское представление станет владельцем коллекции объектов или выделения, перенесите владение туда и передавайте этому представлению нужные значения или привязки. Документация Apple о состоянии.
Как проверить отображение, прежде чем добавлять другие жесты?
Начните с известных точек модели и вычислите ожидаемые локальные позиции. В этих контрольных точках используются начальные значения масштаба и панорамирования из примера:
| Точка модели | Ожидаемая точка в локальных координатах холста | Ожидаемый результат |
|---|---|---|
(70, 40) |
(180, 110) |
Прямоугольник 1 |
(140, 90) |
(320, 210) |
Прямоугольник 2, в области пересечения |
(10, 10) |
(60, 50) |
Ни один прямоугольник |
Локальные позиции отсчитываются от самого холста; панель инструментов и внешние поля не входят в эту систему координат. Проведите указателем по обоим прямоугольникам, отпустите его в области пересечения, затем попробуйте «Уменьшить масштаб» и «Панорамировать вправо». При каждом изменении подсветка должна следовать за видимой геометрией.
Наглядное обратное вычисление и 12 проверок прямого и обратного преобразования были выполнены с помощью арифметики Python. В исследовательской среде не было компилятора Swift и среды выполнения интерфейса macOS, поэтому код Swift был проверен, но приложение не компилировалось и не тестировалось интерактивно.
Если проверка не проходит, симптом поможет сузить причину:
- Постоянное смещение указывает на то, что обработчик ввода привязан к другому представлению или что смещение панорамирования не учтено.
- Ошибка, растущая с расстоянием, указывает на пропущенное преобразование масштаба или несовпадающий порядок преобразований.
- Отражение по вертикали указывает на лишний переворот оси y или на точку AppKit, которую не преобразовали.
- Неверный выбор только в областях пересечения указывает на несогласованность порядка рисования и проверки попадания.
Храните исходную локальную точку наведения, как в примере, и вычисляйте точку модели по текущему отображению. Так вы не сохраните точку наведения в координатах модели, вычисленную при прежнем масштабе. Для выбора используется собственное положение события жеста, поэтому он не зависит от предыдущего вызова обратного отсчёта наведения.
Что меняется, если событие поступает из AppKit?
Если существующее пользовательское представление AppKit уже обрабатывает взаимодействие мышью, преобразуйте событие на этой границе, прежде чем применять обратное преобразование рисования. NSEvent.locationInWindow обычно использует базовые координаты связанного окна. Apple отмечает, что событие с окном nil может вместо этого содержать экранные координаты, поэтому сначала убедитесь, что событие относится к окну принимающего представления. Документация Apple о местоположении события.
Внутри принимающего NSView выражение convert(event.locationInWindow, from: nil) преобразует точку окна в локальные координаты представления. Apple указывает nil как источник координат окна для этого преобразования. Только после этого применяйте обратное преобразование, соответствующее преобразованию, используемому рендерером этого представления. Документация Apple о преобразовании точек.
Проверяйте ориентацию на границе представления. Обычное представление AppKit имеет начало координат в левом нижнем углу, а ось y направлена вверх; у представления, чей isFlipped возвращает true, начало координат находится в левом верхнем углу, а ось y направлена вниз. Пользовательская поверхность рисования AppKit может принять последнее соглашение, чтобы соответствовать этой модели. Пусть преобразование представления обрабатывает различия между системами координат представлений, и не добавляйте после него ещё один переворот. Документация Apple о перевёрнутых координатах.
При подключении такого представления через NSViewRepresentable оставьте обработку событий AppKit в представленном представлении, а результаты передавайте через явный обратный вызов или координатор. Обновляйте это соединение во время обновлений и освобождайте принадлежащие вам ресурсы при завершении работы. SwiftUI управляет компоновкой представленного представления, поэтому не задавайте его frame или bounds, чтобы компенсировать ошибки отображения. Документация Apple о жизненном цикле representable.
В чистом примере SwiftUI оставьте обработку взаимодействия привязанной к холсту. Если его заменит поверхность AppKit, передайте этой поверхности владение областью взаимодействия. Преобразование координат должно переводить событие, поступившее от уже существующего владельца; оно не должно требовать отправки второй копии в другой обработчик выбора, конкурирующий с первым.
Как развивать отображение для более крупного холста?
В примере намеренно поддерживаются равномерное положительное масштабирование и перенос. Если вы добавите поворот, неравномерное масштабирование или другое начало координат для рисования, замените простое обратное преобразование обратным преобразованием полного преобразования рисования. Перед взаимодействием отклоняйте необратимые отображения и сохраняйте проверки прямого и обратного преобразования с известными точками.
Для перетаскивания объектов в будущем запишите выбранный объект и смещение точки захвата в координатах модели при начале жеста. Обновляйте тот же объект на протяжении всего жеста; явно определите, остаётся ли отображение области просмотра неизменным или меняется во время перетаскивания. Повторная проверка попадания для выбора нового объекта при каждом перемещении меняла бы владельца взаимодействия при пересечении фигур.
Используйте одну систему координат области просмотра, одно отображение для рисования и соответствующее ему обратное преобразование. Когда они согласованы, наведение и выбор могут работать со стабильной геометрией модели, пока масштабирование и панорамирование меняют её отображение.
Источники
- Canvas — Apple
- onContinuousHover(coordinateSpace:perform:) — Apple
- CoordinateSpaceProtocol — Apple
- translateBy(x:y:) — Apple
- scaleBy(x:y:) — Apple
- Структуры и классы — Swift.org
- contentShape(_:eoFill:) — Apple
- DragGesture — Apple
- DragGesture.Value — Apple
- State — Apple
- locationInWindow — Apple
- convert(_:from:) — Apple
- isFlipped — Apple
- NSViewRepresentable — Apple