При завершении перетаскивания по холсту вычислите конечную геометрию элемента в системе координат целевого объекта. Сначала проверьте полное вхождение, затем пересечение с ненулевой площадью. В этом руководстве реализовано это правило для прямоугольных элементов в вашем приложении для Mac и объяснен его эквивалент в AppKit.
Определите, что считать перекрытием и полным вхождением
Представьте простую рабочую область на компьютере с перемещаемым синим прямоугольником и неподвижной серой целью. Задача пользователя — определить, полностью ли отпущенный элемент помещается внутри цели, частично ли перекрывает ее или не имеет с ней общей площади. Все данные в примере синтетические и хранятся в памяти.
Полное вхождение направленно: цель должна содержать элемент. Согласно документации Apple, CGRectContainsRect(target, item) возвращает true, когда объединение прямоугольников совпадает с целевым прямоугольником. Это включает случай, когда элемент точно совпадает с целью. Проверка полного вхождения в первую очередь не позволяет классифицировать полностью содержащийся элемент как частичное перекрытие. Документация CGRectContainsRect
В этом примере перекрытие означает наличие общей области с положительной шириной и высотой. Простое касание краями или углами считается отсутствием перекрытия по площади. Вычислите пересечение и проверьте его размеры, чтобы поведение при касании было задано явно. Согласно документации Apple, CGRectIntersection возвращает общий прямоугольник или пустой прямоугольник, если пересечения нет. Документация CGRectIntersection
| Результат | Используемое в этом руководстве правило | Пример |
|---|---|---|
Contained |
Цель содержит элемент целиком, включая совпадающие границы | Элемент помещается внутри цели или точно совпадает с ней |
Overlap |
Полного вхождения нет, но общая область имеет положительную ширину и высоту | Элемент пересекает границу цели |
No area overlap |
Нет пересечения с положительной площадью | Элементы раздельны или только касаются краями |
Invalid geometry |
Координаты одного из прямоугольников не являются конечными числами или его размеры неположительны | Элемент нулевой ширины |
Это правила приложения, основанные на геометрических операциях. Если интерфейсу нужен отступ внутри цели, задайте его отдельно и проверяйте элемент относительно меньшего прямоугольника допустимой области. Сделайте видимую цель и правило проверки понятными для пользователя, который перетаскивает элемент.
Используйте одну систему координат и итоговое смещение перетаскивания
Нарисованный прямоугольник и прямоугольник, который вы классифицируете, должны описывать одно и то же положение. В этом примере и элемент, и цель заданы в координатах холста. Холсту назначается именованная система координат, а DragGesture элемента использует то же имя. Текущий модификатор coordinateSpace(_:) от Apple поддерживает такой подход в macOS 14 и более поздних версиях. Документация по именованным системам координат
Храните зафиксированный прямоугольник элемента отдельно от временного смещения перетаскивания. Во время жеста отображайте зафиксированный прямоугольник со смещением. После успешного завершения заново вычислите конечный прямоугольник по значению из обработчика, классифицируйте его и зафиксируйте результат один раз.
Apple определяет translation как общее смещение от начала перетаскивания до текущего события. Поэтому повторное прибавление всего смещения к уже обновленному положению приведет к чрезмерному перемещению. Применяйте его к неизмененному зафиксированному положению. Документация по смещению перетаскивания
Одной точки отпускания недостаточно, чтобы определить, помещается ли весь элемент внутри цели. Пользователь может схватить прямоугольник за угол, и большая его часть останется снаружи, даже если указатель находится внутри. Применение смещения также сохраняет исходное положение захвата и не перемещает центр элемента к указателю.
Создайте полный пример с прямоугольниками в SwiftUI
Создайте в Xcode приложение SwiftUI для macOS с целевой версией macOS 14 или новее. Замените сгенерированную точку входа приложения двумя блоками ниже, расположив их подряд в одном файле Swift. Удалите исходную точку входа, чтобы в целевом объекте осталось только одно объявление @main. Вместе эти два блока образуют полную программу.
Первый блок определяет классификатор геометрии. Он явно отклоняет недопустимые размеры, а не преобразует их незаметно в другой прямоугольник. Перечисление Swift позволяет различать все четыре возможных результата; строковые raw-значения используются для отображаемых сообщений. Документация по перечислениям Swift
import SwiftUI
import CoreGraphics
enum Placement: String {
case contained = "Contained"
case overlap = "Overlap"
case separate = "No area overlap"
case invalid = "Invalid geometry"
}
func isUsable(_ rect: CGRect) -> Bool {
let values = [rect.origin.x, rect.origin.y,
rect.width, rect.height,
rect.maxX, rect.maxY]
return values.allSatisfy { $0.isFinite }
&& rect.width > 0 && rect.height > 0
}
func classify(item: CGRect, target: CGRect) -> Placement {
guard isUsable(item), isUsable(target) else {
return .invalid
}
if CGRectContainsRect(target, item) {
return .contained
}
let shared = CGRectIntersection(item, target)
if !shared.isNull && shared.width > 0 && shared.height > 0 {
return .overlap
}
return .separate
}
Второй блок содержит приложение и холст. Прикрепите жест к перемещаемому прямоугольнику, как описано в документации Apple по DragGesture. Фиксированный размер холста делает геометрию этого примера явной. Документация DragGesture
@main
struct RectanglePlacementApp: App {
var body: some Scene {
WindowGroup {
PlacementCanvas()
}
}
}
struct PlacementCanvas: View {
private let target = CGRect(x: 200, y: 100,
width: 200, height: 160)
@State private var item = CGRect(x: 40, y: 120,
width: 80, height: 60)
@State private var message = "Drag the blue item, then release."
@GestureState private var translation: CGSize = .zero
private var preview: CGRect {
item.offsetBy(dx: translation.width,
dy: translation.height)
}
private var move: some Gesture {
DragGesture(minimumDistance: 1,
coordinateSpace: .named("placementCanvas"))
.updating($translation) { value, state, _ in
state = value.translation
}
.onEnded { value in
let finalItem = item.offsetBy(
dx: value.translation.width,
dy: value.translation.height
)
let result = classify(item: finalItem, target: target)
message = result.rawValue
if result != .invalid {
item = finalItem
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(message)
ZStack(alignment: .topLeading) {
Color.white
Rectangle()
.fill(Color.gray.opacity(0.25))
.frame(width: target.width, height: target.height)
.position(x: target.midX, y: target.midY)
Rectangle()
.fill(Color.blue)
.frame(width: item.width, height: item.height)
.position(x: preview.midX, y: preview.midY)
.gesture(move)
}
.frame(width: 640, height: 380)
.coordinateSpace(.named("placementCanvas"))
Button("Reset") {
item = CGRect(x: 40, y: 120, width: 80, height: 60)
message = "Drag the blue item, then release."
}
}
.padding()
}
}
Перетащите синий элемент на серую цель и отпустите его. Сообщение показывает результат последнего завершенного перемещения. Элемент остается там, где вы его отпустили, как при частичном перекрытии, так и при полном разделении; полное вхождение не меняет автоматически родительский объект и не перемещает элемент в другой контейнер. Кнопка Reset восстанавливает исходный прямоугольник.
Обновление позиции при этом намеренно не выполняется в обработчике обновления жеста. @GestureState хранит только временное смещение для предварительного просмотра и сбрасывается, когда жест становится неактивным. Apple указывает, что onEnded вызывается только после успешного завершения, а для значений, которые должны сбрасываться независимо от того, как закончился жест, рекомендует использовать состояние жеста. Поэтому при прерывании жеста предварительный просмотр может быть отброшен, а зафиксированный прямоугольник и предыдущий результат сохранятся. Документация GestureState, документация onEnded
В этой реализации геометрией считаются залитые прямоугольники. Декоративная тень или контур не расширяют область допустимого размещения. Если вы добавите изменение размера, масштабирование или поворот, сначала обновите модель геометрии и преобразование координат, прежде чем полагаться на тот же классификатор.
Проверьте граничные случаи, прежде чем добавлять элементы
Используйте следующие синтетические случаи с целью из примера: начало координат (200, 100), ширина 200, высота 160. Координаты задают прямоугольники элементов в той же системе координат холста.
| Начало координат элемента | Размер элемента | Ожидаемый результат | Причина |
|---|---|---|---|
(220, 120) |
80 × 60 |
Contained |
Все края находятся внутри цели |
(360, 120) |
80 × 60 |
Overlap |
Элемент выходит за правую границу цели |
(400, 120) |
80 × 60 |
No area overlap |
Касаются только вертикальные края |
(401, 120) |
80 × 60 |
No area overlap |
Прямоугольники разделены промежутком |
(200, 100) |
200 × 160 |
Contained |
Совпадающие прямоугольники удовлетворяют условию полного вхождения |
(180, 80) |
240 × 200 |
Overlap |
Элемент окружает цель, меняя направление вхождения на противоположное |
Арифметические вычисления в таблице проверены с помощью независимого детерминированного расчета. Код Swift был изучен, но не скомпилирован: в исследовательской среде не было компилятора Swift. Соберите его в целевом объекте приложения для macOS и проверьте положения отпускания возле каждой границы.
Для нескольких хранящихся в памяти элементов сохраните идентификатор элемента, выбранного при начале перетаскивания. После завершения сравните его конечную геометрию с подходящими целями и исключите сам элемент. Если подходят несколько целей, задайте стабильное правило выбора, например явный порядок целей. Одна лишь классификация геометрии не определяет, какой цели должен принадлежать элемент.
Примените то же геометрическое правило в холсте AppKit
Если у вас уже есть собственный вид AppKit, не связывайте классификатор с обработкой событий. mouseUp(with:) сообщает об отпускании левой кнопки мыши; используйте его, чтобы завершать только перетаскивание, которым уже управляет ваш холст. Постороннее событие отпускания мыши не должно запускать размещение. Документация AppKit по отпусканию кнопки мыши
Практическая последовательность обработки событий:
- В
mouseDown(with:)выберите синтетический элемент, используя существующее правило проверки попаданий холста. Сохраните его идентификатор, исходный прямоугольник и начальное положение указателя. - Преобразуйте каждое нужное положение мыши в координаты холста. Для вида, присоединенного к окну, выражение преобразования выглядит так:
convert(event.locationInWindow, from: nil). - В
mouseDragged(with:)вычислите предварительное положение по сохраненному прямоугольнику и смещению указателя. - В
mouseUp(with:)заново вычислите смещение по событию отпускания, классифицируйте конечный прямоугольник, затем зафиксируйте его один раз и сбросьте состояние активного перетаскивания.
Apple указывает, что locationInWindow — это положение события мыши в координатах окна. Вариант метода NSView.convert(_:from:) для преобразования точки переводит координаты из системы окна, когда аргумент вида равен nil. Так вы не сравниваете точку в координатах окна с прямоугольником в локальных координатах холста. Документация по положению события, документация по преобразованию точки
Обработку событий оставьте компоненту, который уже управляет взаимодействием. Apple различает подкласс NSView, напрямую обрабатывающий событие, и подкласс, который должен передавать необработанные события родительскому объекту. Следуйте описанному поведению цепочки ответчиков: добавление логики размещения не оправдывает перехват последовательности событий другого вида или обработку одного события отпускания в двух независимых обработчиках. Рекомендации по обработке событий в NSView
Учитывайте, что прямоугольники не всегда подходят для проверки фигур
Этот пример дает точный ответ для залитых прямоугольников, стороны которых параллельны осям координат. Для кругов, вогнутых контуров, фигур с отверстиями или повернутых фигур ограничивающие прямоугольники дают лишь предварительную геометрическую проверку. Их общая площадь может включать пространство, не занятое ни одной из видимых фигур.
Проверка попадания указателя внутрь контура также не позволяет установить полное вхождение другой фигуры. NSBezierPath.contains(_:) из AppKit принимает одну точку в системе координат контура; Apple указывает, что метод не учитывает ширину штриха. Поэтому проверка центра элемента показывает только то, находится ли этот центр внутри контура. Документация по проверке принадлежности точки NSBezierPath
Для залитых контуров в macOS 14 и более поздних версиях SwiftUI документирует поддерживаемые логические операции: Path.intersection(_:eoFill:) создает общую залитую область, а Path.subtracting(_:eoFill:) создает часть первой залитой области, находящуюся вне второй. Эти операции позволяют исследовать перекрытие контуров и часть элемента, выходящую за пределы цели. Совместите оба контура в одной системе координат и используйте правило заливки, соответствующее способу их отрисовки. Документация по пересечению контуров, документация по вычитанию контуров
Эти операции применяются к залитым областям и предполагают, что незамкнутые подформы замкнуты. Для штриховой линии или правила приложения о касании границ нужно отдельно определить геометрию. Используйте прямоугольный классификатор для прямоугольных элементов; когда на холсте появятся другие фигуры, продуманно расширьте геометрический слой.
Надежный результат при отпускании обеспечивают три конкретных решения: сохранить начальную геометрию перетаскивания, вычислить конечное положение элемента в системе координат цели и применить явное правило полного вхождения и перекрытия. Эти решения позволяют интерфейсу последовательно сообщать результат, не связывая геометрию с маршрутизацией событий или владением элементами.
Источники
- CGRectContainsRect(::) — Apple
- CGRectIntersection(::) — Apple
- coordinateSpace(_:) — Apple
- translation — Apple
- Перечисления — Swift.org
- DragGesture — Apple
- GestureState — Apple
- onEnded(_:) — Apple
- mouseUp(with:) — Apple
- locationInWindow — Apple
- convert(_:from:) — Apple
- NSView — Apple
- contains(_:) — Apple
- intersection(_:eoFill:) — Apple
- subtracting(_:eoFill:) — Apple