Добавьте перетаскивание родительского элемента с помощью simultaneousGesture(_:including:), чтобы оно распознавалось одновременно с взаимодействиями дочерних элементов. Разместите действия нажатия в дочерних кнопках, задайте порог перемещения для перетаскивания и явно решите, может ли выбор элемента происходить одновременно с перетаскиванием.
В этом руководстве создаётся перемещаемая панель с тремя кнопками элементов-заглушек. Задача пользователя конкретна: нажатие на элемент выбирает его, а перетаскивание панели меняет её положение в том же приложении Mac. В примере выбор и перемещение могут происходить одновременно; отдельный вариант с ручкой перетаскивания позволяет выделить разные области для взаимодействия, если это предпочтительнее.
Выберите правила взаимодействия до изменения приоритета жестов
Apple описывает simultaneousGesture(_:including:) как добавление жеста с тем же приоритетом, что и у существующих жестов. В примере Apple обработчик нажатия дочернего элемента и обработчик нажатия охватывающего его контейнера реагируют на один щелчок. Это подходящий механизм композиции, когда родительский элемент должен участвовать во взаимодействии, не получая намеренного преимущества перед дочерним. Документация Apple об одновременных жестах
Однако одновременное распознавание само по себе не задаёт правило «перетаскивание должно отменять все действия дочерних элементов». Пример Apple также не доказывает, что любой стиль кнопки и любой контейнер одинаково разрешают конфликт между кнопкой и перетаскиванием. Рассматривайте участие жестов и поведение приложения как отдельные решения.
В этой панели родительский элемент отвечает за смещение панели и выбранный элемент. Каждый дочерний элемент содержит кнопку, которая запрашивает выбор через замыкание. Запрос выбора остаётся допустимым, даже если то же взаимодействие указателя перемещает панель. Если требуемое поведение запрещает активацию во время перемещения, используйте приведённый ниже вариант с отдельной ручкой перетаскивания, а не полагайтесь на недокументированный порядок вызова обработчиков.
Для действия дочернего элемента используйте Button. Apple прямо рекомендует кнопки, когда взаимодействию нужно поведение кнопки, включая стандартные возможности доступности. Меткой кнопки может быть пользовательская фигура; превращать её в обычный жест нажатия не требуется. Документация Apple о жестах
Создайте полноценную перемещаемую панель с дочерними кнопками
Создайте приложение SwiftUI для macOS в Xcode, задайте целевую версию macOS 14 или новее и замените сгенерированные приложение и представление содержимого следующим файлом. В целевом объекте должна остаться только одна точка входа приложения. Требование macOS 14 связано с используемым здесь современным модификатором coordinateSpace(_:). Документация Apple о системах координат
Все элементы и состояние существуют в памяти. Пример перемещает всю панель вместе с кнопками по неподвижной рабочей области. Он не реализует перенос элементов и не перемещает окно приложения.
import SwiftUI
@main
struct GesturePanelApp: App {
var body: some Scene {
WindowGroup {
PanelDemo()
}
}
}
private struct DemoItem: Identifiable {
let id: Int
let title: String
let color: Color
}
private struct ItemTarget: View {
let item: DemoItem
let isSelected: Bool
let select: () -> Void
var body: some View {
Button(action: select) {
HStack(spacing: 10) {
Circle()
.fill(item.color)
.frame(width: 18, height: 18)
Text(item.title)
Spacer()
if isSelected {
Text("Selected")
.font(.caption)
}
}
.padding(12)
.frame(width: 220)
.background(
isSelected
? Color.accentColor.opacity(0.18)
: Color.gray.opacity(0.12)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private struct PanelDemo: View {
private let items = [
DemoItem(id: 1, title: "Item A", color: .blue),
DemoItem(id: 2, title: "Item B", color: .orange),
DemoItem(id: 3, title: "Item C", color: .purple)
]
@State private var selectedID: Int? = nil
@State private var committedOffset: CGSize = .zero
@GestureState private var liveTranslation: CGSize = .zero
private var pan: some Gesture {
DragGesture(
minimumDistance: 8,
coordinateSpace: .named("stage")
)
.updating($liveTranslation) { value, translation, _ in
translation = value.translation
}
.onEnded { value in
committedOffset = CGSize(
width: committedOffset.width + value.translation.width,
height: committedOffset.height + value.translation.height
)
}
}
var body: some View {
VStack(spacing: 12) {
Text("Click an item or drag the panel")
ZStack {
Color.gray.opacity(0.06)
VStack(spacing: 10) {
Text("Drag panel")
.font(.headline)
.frame(width: 244, height: 32)
.contentShape(Rectangle())
ForEach(items) { item in
ItemTarget(
item: item,
isSelected: selectedID == item.id,
select: { selectedID = item.id }
)
}
}
.padding(16)
.background(Color.gray.opacity(0.15))
.contentShape(Rectangle())
.simultaneousGesture(pan, including: .all)
.offset(
x: committedOffset.width + liveTranslation.width,
y: committedOffset.height + liveTranslation.height
)
}
.frame(width: 640, height: 420)
.coordinateSpace(.named("stage"))
Button("Reset panel") {
committedOffset = .zero
}
}
.padding(20)
}
}
Эта программа проверена по полученной документации API, но здесь она не компилировалась и не тестировалась интерактивно: в этой среде нет компилятора Swift и среды выполнения пользовательского интерфейса macOS. Соберите её в Xcode и выполните проверки ниже в поддерживаемых вами версиях macOS.
Зачем перетаскиванию нужен ненулевой порог
DragGesture задаёт минимальное расстояние перемещения, после которого жест срабатывает. Примерное значение 8 позволяет обычному нажатию немного сместиться, прежде чем родительский элемент начнёт перетаскиваться. Это решение приложения, а не порог, предписанный Apple, и не полная политика различения нажатия и перетаскивания. Перетаскивание с нулевым расстоянием устраняет это конкретное различие. Документация Apple о DragGesture
Зачем разделять временное и сохранённое перемещение
Во время распознавания updating(_:body:) записывает текущее смещение в liveTranslation. Apple указывает, что состояние жеста сбрасывается после его завершения или отмены. В примере итоговое смещение сохраняется только в onEnded, поэтому при отмене незавершённый предпросмотр отбрасывается и не оставляет устаревшее временное смещение. Документация Apple о updating
Отображаемое положение равно сохранённому смещению плюс текущее смещение. Apple определяет translation как общее перемещение от начала перетаскивания, поэтому добавление его к сохранённому смещению при каждом изменении многократно учитывало бы перемещение. Например, сохранённое горизонтальное смещение 40 и текущее смещение 12 дают отображаемое смещение 52; при завершении перетаскивания значение 52 сохраняется один раз. Документация Apple о translation
Именованная система координат принадлежит неподвижной рабочей области, окружающей перемещаемую панель. Благодаря этому расчёт перемещения привязан к явно заданной опорной области. Дочерние кнопки не хранят собственные копии положения панели.
Почему выбор элемента обрабатывает родительский элемент
Родительский элемент хранит selectedID и передаёт каждому дочернему элементу его текущий статус выбора и замыкание для выбора. Это соответствует рекомендации Apple хранить состояние в самом верхнем представлении, которому оно требуется, а затем передавать значения или привязки вниз по иерархии. Документация Apple о State
Замыкание захватывает текущий элемент и запрашивает обновление состояния родительского элемента. Swift поддерживает захват замыканиями значений из окружающего контекста; для реализации этого обратного вызова не нужно копировать или пересылать событие AppKit. Язык программирования Swift: замыкания
Разберитесь в областях попадания и масках жестов
contentShape(Rectangle()) задаёт форму для проверки попадания. В метке кнопки он следует за отступами и рамкой, явно определяя предполагаемую прямоугольную область метки. У родительского элемента он задаёт область взаимодействия панели. Он не устанавливает приоритет распознавателя и не требует согласованной работы двух жестов. Документация Apple о content shape
Маска, переданная модификатору жеста, определяет, какие жесты остаются включёнными. Эти варианты легко понять неправильно: subviews означает, что добавленный жест родительского элемента отключён, а не то, что он автоматически взаимодействует с дочерними элементами. Документация Apple о GestureMask
| Маска | Задокументированный эффект | Подходит ли здесь |
|---|---|---|
.all |
Включает добавленный жест и другие жесты представления и его дочерних элементов | Отправная точка для совместного участия |
.gesture |
Включает добавленный жест и отключает жесты в иерархии дочерних элементов | Мешает сохранить дочерние жесты |
.subviews |
Включает жесты дочерних элементов и отключает добавленный жест | Отключает перетаскивание родителя |
.none |
Отключает жесты в иерархии дочерних элементов, включая добавленный жест | Отключает нужное взаимодействие |
Не рассматривайте highPriorityGesture как универсальное решение проблемы отсутствия вызовов обработчика перетаскивания родительского элемента. Его задокументированное назначение — повысить приоритет прикреплённого жеста. В примере Apple родительский элемент получает приоритет над нажатием дочернего. Используйте этот вариант, если такое преимущество соответствует нужному поведению. Документация Apple о жестах с высоким приоритетом
Используйте отдельную ручку перетаскивания, если активация должна быть отделена
Если кнопки панели не должны находиться в общей области перетаскивания, сохраните те же состояние и расчёт перемещения, но прикрепите pan только к заголовку. Удалите .simultaneousGesture(pan, including: .all) с родительской панели.
Замените существующее выражение заголовка этим фрагментом внутри приведённой выше полной программы. В нём используется уже определённое свойство pan; остальные предварительные условия не меняются.
Text("Drag panel")
.font(.headline)
.frame(width: 244, height: 32)
.contentShape(Rectangle())
.gesture(pan)
Теперь заголовок и кнопки элементов занимают разные области, а кнопки больше не наследуют жест перетаскивания панели от родительского элемента. Нажатие на элемент запускает взаимодействие с кнопкой; для перемещения панели нужно начать перетаскивание с заголовка. Такой подход полезен, когда совместное распознавание делает результат неоднозначным.
Если дочерний элемент запускает действие, оставьте нативную кнопку. Apple описывает Button как действие с меткой и допускает пользовательские представления метки, как в случае с синтетическими цветными элементами. Замена кнопки собственным обработчиком указателя потребовала бы заново реализовать поведение, уже предоставляемое этим элементом управления. Документация Apple о Button
Сохраняйте владение событиями AppKit на границах фреймворков
Если панель содержит представление AppKit, анализируйте его проверку попадания отдельно от композиции жестов SwiftUI. Метод AppKit hitTest(_:) возвращает наиболее глубокий подходящий дочерний элемент и помогает окну определить получателя события нажатия кнопки мыши. Переданная ему точка задаётся в координатах представления-родителя. Документация Apple о проверке попадания в AppKit
Возврат родительского элемента из проверки попадания меняет первоначальную цель; это не равнозначно одновременному распознаванию жеста дочерним и родительским элементами. Аналогично, возврат nil при проверке попадания наложенного представления исключает это наложение из рассмотрения, но не превращает его в пассивного наблюдателя за всем взаимодействием дочернего элемента.
В документации AppKit указано, что реализация mouseDown(with:) по умолчанию передаёт сообщение следующему респонденту. Это поведение цепочки респондентов, а не обещание, что каждый родительский элемент независимо получит копию события после его обработки элементом управления. Связывайте активацию элемента управления и перемещение панели явными обратными вызовами или принадлежащими вам распознавателями. Документация Apple о mouse-down
Если вы владеете двумя распознавателями жестов AppKit и хотите разрешить их одновременное распознавание, AppKit предоставляет gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:). Apple указывает, что возврат true разрешает паре распознаваться одновременно; возврат false не гарантирует взаимоисключение, если делегат другого распознавателя его разрешает. Ограничьте эту политику нужной парой распознавателей. Документация Apple о делегате распознавателя
Проверьте взаимодействие с помощью небольшой таблицы поведения
Проверьте конкретный стиль кнопок, иерархию и версии macOS, которые поддерживает приложение. Следующие проверки помогут отделить проблемы распознавания от арифметики перемещения и определить, соответствует ли общая область вашим правилам взаимодействия.
| Действие | Что проверить |
|---|---|
| Нажать каждый элемент, не перетаскивая | Его состояние выбора меняется; панель остаётся на месте |
| Перетащить заголовок дальше порога | Панель следует за указателем и сохраняет итоговое положение |
| Перетащить элемент в варианте с общей областью | Родительская панель перемещается; определите, происходит ли также активация кнопки, и подтвердите, что это допустимо |
| Выполнить два перетаскивания подряд | Второе начинается с первого сохранённого положения, без накопления промежуточных смещений |
| Прервать активное перетаскивание | Временное смещение сбрасывается; незавершённое перемещение не сохраняется |
| Проверить вариант с ручкой | Элементы активируются независимо; перемещение начинается с заголовка |
| Активировать кнопки элементов с помощью клавиатурной навигации | Выбор работает без перетаскивания указателем |
Если пустая область панели перетаскивается правильно, а области элементов — нет, проверьте дочерние элементы управления и композицию жестов. Если нажатия перестали работать, проверьте маски жестов, приоритет и области попадания. Если панель ускоряется или прыгает, прежде чем добавлять другие распознаватели, проверьте накопление смещения и выбранную систему координат.
В примере намеренно нет ограничений перемещения, поэтому панель может выйти за пределы видимой рабочей области; кнопка Reset panel восстанавливает её положение. Для готовой рабочей области с ограничениями добавьте явное ограничение положения, учитывающее размеры рабочей области и панели. Сохраните ту же структуру владения: дочерние элементы запрашивают действия, родительский элемент отвечает за перемещение, а правила взаимодействия определяют, перекрываются ли области распознавания.
Источники
- simultaneousGesture(_:including:) — Apple
- Жесты — Apple
- coordinateSpace(_:) — Apple
- DragGesture — Apple
- updating(_:body:) — Apple
- translation — Apple
- State — Apple
- Замыкания — Язык программирования Swift, проект Swift
- contentShape(_:eoFill:) — Apple
- GestureMask — Apple
- highPriorityGesture(_:including:) — Apple
- Button — Apple
- hitTest(_:) — Apple
- mouseDown(with:) — Apple
- gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) — Apple