Не меняйте сохранённую позицию фигуры, пока SwiftUI отображает перетаскивание, а затем сохраните итоговое смещение в onEnded. Так исходная позиция сохраняется до завершения жеста, и при этом не нужно полагаться на момент получения события отпускания кнопки мыши в AppKit. Ниже приведён полный пример перемещения прямоугольника в собственном приложении для Mac.
Какая граница завершения должна отвечать за исходное состояние?
Для взаимодействия с перемещением фигуры конкретная задача состоит в том, чтобы вычислять каждую промежуточную позицию и итоговую позицию относительно одной и той же исходной позиции. Подходящая граница завершения — успешное завершение жеста этой фигуры. Согласно документации Apple, onEnded(_:) передаёт итоговое значение жеста и вызывается только при его успешном завершении. Для временного состояния, которое должно сбрасываться независимо от того, как закончился жест, Apple рекомендует @GestureState. Документация Apple по onEnded(_:).
Разделите состояние взаимодействия на два вида: сохранённую позицию модели и временное смещение для предварительного отображения. Во время перетаскивания показывайте их сумму. При успешном завершении один раз примените итоговое смещение к сохранённой позиции. Такая схема основана на различии, которое Apple проводит между временными обновлениями жеста и постоянными обновлениями состояния. Добавление интерактивности с помощью жестов.
| Информация | Владелец в этом примере | Срок жизни |
|---|---|---|
| Сохранённый центр прямоугольника | Частная модель @State |
Сохраняется после завершения жеста |
| Текущее смещение для предварительного отображения | @GestureState |
Сбрасывается, когда жест становится неактивным |
| Итоговое смещение | Аргумент обратного вызова onEnded |
Синхронно используется для фиксации перемещения |
| Событие отпускания кнопки мыши AppKit | Обработка событий AppKit | Сообщает об отпускании кнопки; не определяет момент завершения жеста SwiftUI |
Apple описывает @State как хранилище, которым управляет SwiftUI, и источник истины для значений в иерархии представлений. @GestureState автоматически сбрасывается, когда связанный с ним жест становится неактивным. Если хранить исходную позицию в первом свойстве, а предварительное смещение — во втором, срок жизни каждого значения будет соответствовать потребностям этого взаимодействия. State, GestureState.
Создайте полный пример перетаскивания прямоугольника для Mac
Создайте в Xcode приложение SwiftUI для macOS с целевой версией macOS 14 или новее. Замените сгенерированные объявление приложения и представление содержимого приведённым ниже файлом, убедившись, что в цели есть только одно объявление @main. В примере используется один искусственный прямоугольник, состояние которого хранится только в памяти. Согласно документации Apple, API именованных систем координат доступны начиная с macOS 14. Инициализатор жеста перетаскивания, Именованные системы координат.
import SwiftUI
@main
struct GestureStartDemoApp: App {
var body: some Scene {
WindowGroup {
ShapeCanvas()
}
}
}
private struct SampleItem {
var center = CGPoint(x: 160, y: 140)
}
private struct ShapeCanvas: View {
@State private var item = SampleItem()
@GestureState private var previewTranslation = CGSize.zero
private let canvasWidth: CGFloat = 600
private let canvasHeight: CGFloat = 360
private let shapeWidth: CGFloat = 80
private let shapeHeight: CGFloat = 60
private func projectedCenter(_ translation: CGSize) -> CGPoint {
let proposedX = item.center.x + translation.width
let proposedY = item.center.y + translation.height
let halfWidth = shapeWidth / 2
let halfHeight = shapeHeight / 2
return CGPoint(
x: min(max(proposedX, halfWidth),
canvasWidth - halfWidth),
y: min(max(proposedY, halfHeight),
canvasHeight - halfHeight)
)
}
private var moveGesture: some Gesture {
DragGesture(
minimumDistance: 3,
coordinateSpace: .named("shapeCanvas")
)
.updating($previewTranslation) { value, translation, _ in
translation = value.translation
}
.onEnded { value in
let finalCenter = projectedCenter(value.translation)
item.center = finalCenter
}
}
var body: some View {
let displayedCenter = projectedCenter(previewTranslation)
VStack(alignment: .leading, spacing: 12) {
Text("Drag the blue rectangle")
.font(.headline)
ZStack {
Color.gray.opacity(0.12)
RoundedRectangle(cornerRadius: 10)
.fill(Color.blue)
.frame(width: shapeWidth, height: shapeHeight)
.contentShape(Rectangle())
.gesture(moveGesture)
.position(
x: displayedCenter.x,
y: displayedCenter.y
)
}
.frame(width: canvasWidth, height: canvasHeight)
.coordinateSpace(.named("shapeCanvas"))
.clipped()
Text(
"Committed center: " +
"\(Int(item.center.x)), \(Int(item.center.y))"
)
.monospacedDigit()
}
.padding(20)
}
}
Ключевой инвариант: item.center не меняется в updating. Оно остаётся исходной позицией модели на протяжении жеста, пока onEnded не вычислит finalCenter. Итоговое вычисление напрямую читает value.translation, поэтому свойству предварительного отображения не нужно сохранять последнее значение до завершения жеста.
Жест измеряет перемещение в именованной системе координат содержащего холста. Модификатор position(x:y:) размещает центр прямоугольника в той же родительской системе координат. Совпадение систем координат позволяет не смешивать локальные координаты перемещаемой фигуры с координатами холста. Apple описывает как измерение жеста в именованной системе координат, так и позиционирование по центру. coordinateSpace(_:), position(x:y:).
Общая функция projectedCenter также удерживает прямоугольник внутри холста. Для предварительного отображения и фиксации перемещения используются одинаковые ограничения: горизонтальная координата центра находится в диапазоне от 40 до 560, а вертикальная — от 30 до 330. У края перемещение по-прежнему рассчитывается как полное смещение от начала жеста; прямоугольник отходит от края, когда предполагаемая позиция снова оказывается в пределах холста.
Перемещение меньше настроенного порога распознавания 3 пунктов не считается распознанным перетаскиванием. В этом примере началом считается момент распознавания взаимодействия по перемещению фигуры. Требование фиксировать состояние модели в момент физического нажатия кнопки мыши относится к другой границе. Инициализатор жеста перетаскивания.
Почему исходная позиция сохраняется при итоговом обновлении
Apple определяет DragGesture.Value.translation как полное смещение от начала жеста до текущего события. Оно накапливается, а не отражает разницу с предыдущим обратным вызовом. Поэтому, если прибавлять каждое переданное смещение к уже обновлённой позиции модели, перемещение будет посчитано несколько раз. DragGesture.Value.
Рассмотрим наглядные условные входные данные, которые остаются в пределах холста:
| Этап | Переданное полное смещение | Сохранённый центр | Отображаемый центр |
|---|---|---|---|
| До перетаскивания | (0, 0) |
(160, 140) |
(160, 140) |
| Первое обновление | (12, 5) |
(160, 140) |
(172, 145) |
| Второе обновление | (30, -10) |
(160, 140) |
(190, 130) |
| Успешное завершение | (45, 20) |
Становится (205, 160) |
Остаётся в (205, 160) |
Все промежуточные позиции вычисляются относительно одной исходной позиции. При завершении итоговое (45, 20) применяется один раз. Следующее успешное перетаскивание со смещением (-15, 10) начинается с (205, 160) и фиксирует (190, 170). Эти вычисления проверены с помощью детерминированного расчёта на Python. Приложение SwiftUI сверено с полученной документацией API, но в этой среде не компилировалось и не запускалось.
Не используйте startLocation вместо исходного центра прямоугольника. По определению Apple, startLocation — это место, в котором произошло первое событие перетаскивания. Указатель может находиться рядом с углом прямоугольника, поэтому это место может не совпадать с центром фигуры в модели. Аналогично, predictedEndTranslation — прогноз на основе скорости; для этого взаимодействия с прямым позиционированием используйте итоговое translation. DragGesture.Value.
В этой схеме отмена не требует откатывать сохранённую позицию, потому что обновления для предварительного отображения её не перезаписывают. Согласно документации Apple, состояние жеста сбрасывается, когда жест становится неактивным, а onEnded вызывается только после успешного завершения. Поэтому временное смещение исчезает, и отменённое перемещение не фиксируется. Этот вывод следует из документированного поведения; он не зависит от предположения о порядке сброса и обратного вызова успешного завершения. GestureState, onEnded(_:).
Почему монитор отпускания кнопки мыши AppKit не должен сбрасывать исходную позицию
Локальный монитор событий AppKit получает события до их передачи приложению. Если сбросить общее состояние начала жеста при отслеживаемом событии отпускания кнопки мыши, оно может быть удалено до того, как последующая обработка получит это событие. Это следует из документированной границы передачи событий, но не из какой-либо документированной гарантии порядка обратных вызовов SwiftUI. addLocalMonitorForEvents(matching:handler:).
Аналогично, NSResponder.mouseUp(with:) сообщает, что пользователь отпустил левую кнопку мыши. Его контракт не обещает, что все обратные вызовы жестов SwiftUI или обновления представлений уже завершились. Наблюдение за необработанными событиями и завершение распознанного жеста должны оставаться отдельными задачами. mouseUp(with:).
Рассмотренные здесь API не определяют универсальный обратный вызов, который означал бы, что SwiftUI завершил всю обработку необработанного события. Отложенный блок очистки или короткий таймер добавили бы предположение о планировании, не устанавливая этой связи. В этой задаче поддерживаемый путь — выполнить итоговое вычисление модели внутри onEnded, где доступно итоговое значение жеста.
Если взаимодействие нужно реализовать в пользовательском представлении AppKit, пусть это представление владеет последовательностью ввода и состоянием сессии, а интеграция выполняется через NSViewRepresentable. Apple предоставляет для такой интеграции хуки создания, обновления, координатора и демонтажа. Храните сессию представленного представления в самом представлении AppKit или его координаторе, а не восстанавливайте её заново из каждого значения представления SwiftUI. SwiftUI управляет разметкой представленного представления, поэтому не изменяйте самостоятельно управляемые им frame или bounds. NSViewRepresentable.
Когда взаимодействию нужен явный снимок состояния?
Сохранённой позиции в примере достаточно, потому что во время перетаскивания никакое другое действие её не меняет. Для более крупного холста воспользуйтесь этой таблицей, прежде чем вводить отдельный объект сессии:
| Требование | Следствие для архитектуры |
|---|---|
| Перемещать один элемент, сохраняя его позицию неизменной во время предварительного отображения | Использовать показанный подход с исходной позицией и временным смещением |
| Совместно перемещать несколько выбранных элементов | Сохранить их стабильные идентификаторы и исходные позиции в одном снимке взаимодействия |
| Разрешить другим командам менять позиции во время перетаскивания | Определить, нужно ли блокировать эти изменения, отменять перетаскивание или разрешать конфликт с учётом версии модели |
| Фиксировать состояние при физическом нажатии кнопки мыши до распознавания жеста | Использовать явно заданную границу ввода AppKit; первое обновление жеста не эквивалентно этому моменту |
| Сохранять отдельную сессию после завершения | Назначить ей явного владельца и политику завершения, а не полагаться на временное состояние жеста |
Снимок должен сохранять значения. Структуры Swift имеют семантику значений, а присваивание экземпляра класса копирует ссылку. Поэтому массив ссылок на изменяемые объекты элементов сам по себе не фиксирует их позиции. Для перемещения нескольких элементов сохраните идентификаторы и значения координат, нужные для итогового вычисления. Структуры и классы — язык программирования Swift.
Если вы вводите постоянную сессию, явно определите успешное завершение, отмену и удаление представления. Если очищать её только в onEnded, отмена останется необработанной, поскольку этот обратный вызов предназначен только для успешного завершения. Не используйте транзакцию сброса состояния жеста как универсальное уведомление о завершении: Apple описывает замыкание сброса как предоставляющее транзакцию для сброса состояния жеста. onEnded(_:), init(wrappedValue:reset:).
Для обычного перемещения прямоугольника сохраняйте исходную позицию, не меняя модель при предварительном отображении. Вычислите итоговую позицию по собственному значению успешно завершившегося жеста, зафиксируйте её один раз и позвольте временному состоянию жеста сброситься. Так у исходной позиции будут ясные владелец и срок жизни, достаточные для необходимых вычислений.
Источники
- Добавление интерактивности с помощью жестов — Apple.
- State — Apple.
- GestureState — Apple.
- onEnded(_:) — Apple.
- DragGesture.Value — Apple.
- init(minimumDistance:coordinateSpace:) — Apple.
- coordinateSpace(_:) — Apple.
- position(x:y:) — Apple.
- addLocalMonitorForEvents(matching:handler:) — Apple.
- mouseUp(with:) — Apple.
- NSViewRepresentable — Apple.
- Структуры и классы — язык программирования Swift — проект Swift.org.
- init(wrappedValue:reset:) — Apple.