Füge die Ziehbewegung des übergeordneten Elements mit simultaneousGesture(_:including:) hinzu, damit sie neben den Interaktionen der untergeordneten Elemente stattfinden kann. Belasse Klickaktionen in den untergeordneten Buttons, lege für das Ziehen eine Bewegungsschwelle fest und entscheide ausdrücklich, ob eine Auswahl mit einem Ziehvorgang einhergehen darf.
Diese Anleitung erstellt ein verschiebbares Panel mit drei simulierten Item-Buttons. Die Aufgabe für Leserinnen und Leser ist konkret: Ein Klick auf ein Item wählt es aus, während ein Ziehen des Panels seine Position innerhalb derselben Mac-App ändert. Das Beispiel ermöglicht, dass Auswahl und Verschieben gleichzeitig stattfinden; eine alternative Variante mit separatem Ziehgriff bietet getrennte Interaktionsbereiche, wenn das vorzuziehen ist.
Lege den Interaktionsvertrag fest, bevor du die Gestenpriorität änderst
Apple beschreibt simultaneousGesture(_:including:) als Hinzufügen einer Geste mit derselben Priorität wie bestehende Gesten. Das Beispiel zeigt, wie sowohl ein Tap-Handler eines untergeordneten Elements als auch der Tap-Handler eines umschließenden Containers auf einen Klick reagieren. Dies ist der passende Mechanismus zur Kombination, wenn das übergeordnete Element mitwirken soll, ohne dem untergeordneten Element bewusst Vorrang zu nehmen. Apples Dokumentation zu gleichzeitigen Gesten
Gleichzeitige Erkennung drückt jedoch nicht von selbst die Regel aus: „Beim Ziehen müssen alle Aktionen untergeordneter Elemente abgebrochen werden.“ Apples Beispiel belegt auch nicht, dass jeder Button-Stil und jeder Container einen Button und eine Ziehbewegung identisch priorisiert. Behandle die Teilnahme an Gesten und das Verhalten der App als getrennte Entscheidungen.
Bei diesem Panel verwaltet das übergeordnete Element den Panel-Offset und das ausgewählte Item. Jedes untergeordnete Element enthält einen Button, der über einen Closure eine Auswahl anfordert. Eine Auswahl bleibt zulässig, auch wenn dieselbe Zeigerinteraktion das Panel verschiebt. Wenn das Verhalten bei Bewegung eine Aktivierung ausschließen muss, verwende die nachfolgende Variante mit separatem Ziehgriff, statt dich auf eine nicht dokumentierte Reihenfolge von Callbacks zu verlassen.
Verwende für die Aktion des untergeordneten Elements einen Button. Apple empfiehlt ausdrücklich Buttons, wenn eine Interaktion Button-Verhalten benötigt, einschließlich des standardmäßigen Barrierefreiheitsverhaltens. Eine eigene Form kann weiterhin als Label des Buttons dienen; sie muss nicht zu einer einfachen Tap-Geste werden. Apples Dokumentation zu Gesten
Erstelle ein vollständiges verschiebbares Panel mit untergeordneten Buttons
Erstelle in Xcode eine macOS-SwiftUI-App, setze ihr Deployment-Ziel auf macOS 14 oder neuer und ersetze die generierte App sowie die Content-View durch die folgende einzelne Datei. Belasse im Target nur einen App-Einstiegspunkt. Die Anforderung macOS 14 deckt den hier verwendeten modernen Modifier coordinateSpace(_:) ab. Apples Dokumentation zu Koordinatenräumen
Alle Items und Zustände existieren im Arbeitsspeicher. Das Beispiel verschiebt das gesamte Panel einschließlich seiner Buttons innerhalb einer festen Fläche. Es implementiert weder das Verschieben von Items noch das Verschieben des App-Fensters.
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)
}
}
Dieses Programm wurde anhand der abgerufenen API-Dokumentation geprüft, hier aber weder kompiliert noch interaktiv getestet: In dieser Umgebung gibt es keinen Swift-Compiler und keine macOS-UI-Laufzeit. Erstelle den Build in Xcode und führe die nachfolgenden Prüfungen auf den macOS-Versionen durch, die du unterstützt.
Warum die Ziehbewegung eine von null verschiedene Schwelle hat
DragGesture bietet eine minimale Bewegungsdistanz, die erreicht werden muss, bevor die Geste erfolgreich ist. Der beispielhafte Wert 8 lässt bei gewöhnlichen Klicks einen gewissen Bewegungsspielraum, bevor das übergeordnete Element mit dem Ziehen beginnt. Dies ist eine Entscheidung für die App, kein von Apple vorgeschriebener Schwellenwert und keine vollständige Richtlinie zur Unterscheidung von Klicks und Ziehvorgängen. Eine Ziehbewegung mit null Distanz hebt diese Unterscheidung auf. Apples Dokumentation zu DragGesture
Warum vorübergehende und endgültige Bewegung getrennt sind
Während der Erkennung schreibt updating(_:body:) die aktuelle Verschiebung in liveTranslation. Apple dokumentiert, dass dieser Gestenzustand zurückgesetzt wird, wenn die Geste endet oder abgebrochen wird. Das Beispiel übernimmt den endgültigen Offset nur in onEnded; bei einem Abbruch wird so die nicht übernommene Vorschau verworfen, statt eine veraltete temporäre Verschiebung zurückzulassen. Apples Dokumentation zu updating
Die angezeigte Position ergibt sich aus dem übernommenen Offset plus der aktuellen Verschiebung. Apple definiert translation als die Gesamtverschiebung seit Beginn des Ziehvorgangs. Würde man sie bei jeder Änderung wiederholt zum übernommenen Offset addieren, würde die Bewegung mehrfach gezählt. Zur Veranschaulichung: Ein übernommener horizontaler Offset von 40 und eine aktuelle Verschiebung von 12 ergeben einen angezeigten Offset von 52; beim Beenden des Ziehvorgangs wird 52 einmal übernommen. Apples Dokumentation zu translation
Der benannte Koordinatenraum gehört zur festen Fläche, die das verschiebbare Panel umgibt. Dadurch bezieht sich die Bewegungsberechnung auf einen ausdrücklich festgelegten Bezugsbereich. Die untergeordneten Buttons verwalten keine eigenen Kopien der Panel-Position.
Warum die Auswahl beim übergeordneten Element bleibt
Das übergeordnete Element speichert selectedID und übergibt jedem untergeordneten Element den aktuellen Auswahlstatus sowie einen Closure für die Auswahl. Das entspricht Apples Empfehlung, den Zustand in der höchsten View zu halten, die ihn benötigt, und anschließend Werte oder Bindings in der Hierarchie nach unten weiterzugeben. Apples Dokumentation zu State
Der Closure erfasst das aktuelle Item und fordert eine Aktualisierung des Zustands des übergeordneten Elements an. Swift unterstützt Closures, die Werte aus ihrem umgebenden Kontext erfassen; für diesen Callback muss kein AppKit-Event kopiert oder weitergeleitet werden. Die Swift-Programmiersprache: Closures
Verstehe Trefferbereiche und Gestenmasken
contentShape(Rectangle()) definiert die Form für die Trefferprüfung. Im Button-Label steht es nach Padding und Frame, sodass der beabsichtigte rechteckige Label-Bereich ausdrücklich festgelegt ist. Beim übergeordneten Element definiert es die Interaktionsform des Panels. Es legt weder die Erkennungspriorität fest noch verlangt es, dass zwei Gesten zusammenarbeiten. Apples Dokumentation zu contentShape
Die einer Gestenmodifikation übergebene Maske bestimmt, welche Gesten aktiviert bleiben. Diese Optionen lassen sich leicht missverstehen: subviews bedeutet, dass die hinzugefügte Geste des übergeordneten Elements deaktiviert ist, nicht, dass sie automatisch mit untergeordneten Elementen zusammenarbeitet. Apples Dokumentation zu GestureMask
| Maske | Dokumentierte Wirkung | Eignung für diesen Fall |
|---|---|---|
.all |
Aktiviert die hinzugefügte Geste sowie andere Gesten auf der View und ihren untergeordneten Views | Ausgangspunkt für gemeinsame Teilnahme |
.gesture |
Aktiviert die hinzugefügte Geste und deaktiviert Gesten in der Hierarchie der untergeordneten Views | Widerspricht dem Erhalt untergeordneter Gesten |
.subviews |
Aktiviert Gesten untergeordneter Views und deaktiviert die hinzugefügte Geste | Deaktiviert die Ziehbewegung des übergeordneten Elements |
.none |
Deaktiviert Gesten in der Hierarchie der untergeordneten Views, einschließlich der hinzugefügten Geste | Deaktiviert die beabsichtigte Interaktion |
Behandle highPriorityGesture nicht als allgemeine Lösung für fehlende Callbacks der Ziehbewegung des übergeordneten Elements. Der dokumentierte Zweck ist, der angefügten Geste höhere Priorität zu geben. Apples Beispiel zeigt, wie das übergeordnete Element Vorrang vor einem Tap eines untergeordneten Elements erhält. Verwende es, wenn genau dieser Vorrang beabsichtigt ist. Apples Dokumentation zu hoch priorisierten Gesten
Verwende einen separaten Ziehgriff, wenn Aktivierung getrennt bleiben muss
Wenn die Buttons eines Panels keinen gemeinsamen Ziehbereich haben sollen, behalte denselben Zustand und dieselbe Bewegungsberechnung bei, füge pan aber nur an die Kopfzeile an. Entferne .simultaneousGesture(pan, including: .all) vom Panel des übergeordneten Elements.
Ersetze den vorhandenen Ausdruck für die Kopfzeile durch dieses Fragment innerhalb des vollständigen Programms oben. Es verwendet die bereits definierte Property pan; an den sonstigen Voraussetzungen ändert sich nichts.
Text("Drag panel")
.font(.headline)
.frame(width: 244, height: 32)
.contentShape(Rectangle())
.gesture(pan)
Kopfzeile und Item-Buttons befinden sich nun in getrennten Bereichen, und die Ziehbewegung des Panels ist nicht mehr an einen Vorfahren der Buttons angefügt. Ein Start auf einem Item löst eine Button-Interaktion aus; ein Start auf der Kopfzeile ermöglicht das Verschieben des Panels. Dieses Design ist sinnvoll, wenn die gemeinsame Teilnahme zu einem mehrdeutigen Ergebnis führen könnte.
Behalte den nativen Button bei, wenn das untergeordnete Element eine Aktion auslöst. Apple beschreibt Button als Aktion plus Label und erlaubt eigene Label-Views, wie bei den simulierten farbigen Items. Ersetzt du ihn durch einen selbst gebauten Zeiger-Handler, musst du Verhalten nachbilden, das das Steuerelement bereits bereitstellt. Apples Dokumentation zu Button
Erhalte die Ereigniszuständigkeit von AppKit an Framework-Grenzen
Wenn das Panel eine AppKit-View enthält, untersuche deren Trefferprüfung getrennt von der Zusammensetzung der SwiftUI-Gesten. AppKits hitTest(_:) gibt den tiefsten passenden Nachfahren zurück und hilft dem Fenster, den Empfänger eines Mouse-Down-Events zu bestimmen. Sein Eingabepunkt wird in den Koordinaten der Superview angegeben. Apples Dokumentation zur Trefferprüfung in AppKit
Die Rückgabe eines übergeordneten Elements bei der Trefferprüfung ändert das ursprüngliche Ziel; sie ist nicht gleichbedeutend damit, einem untergeordneten und einem übergeordneten Element gleichzeitige Erkennung zu ermöglichen. Ebenso entfernt die Rückgabe von nil bei der Trefferprüfung eines Overlays dieses Overlay aus der Berücksichtigung – sie macht es nicht zu einem passiven Beobachter der gesamten Interaktion des untergeordneten Elements.
AppKit dokumentiert, dass die Standardimplementierung von mouseDown(with:) die Nachricht an den nächsten Responder weitergibt. Das ist Verhalten der Responder-Kette und keine Zusage, dass jeder Vorfahr nach der Verarbeitung des Events durch ein Steuerelement eine eigene Kopie erhält. Verknüpfe die Aktivierung des Steuerelements und die Panel-Bewegung über explizite Callbacks oder Gestenerkenner, die du selbst verwaltest. Apples Dokumentation zu mouseDown
Wenn du zwei AppKit-Gestenerkenner selbst verwaltest und gleichzeitige Erkennung benötigst, stellt AppKit gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) bereit. Apple dokumentiert, dass die Rückgabe von true dem Paar gleichzeitige Erkennung erlaubt; die Rückgabe von false garantiert keinen Ausschluss, falls der Delegate des anderen Erkenners dies zulässt. Beschränke diese Richtlinie auf das vorgesehene Erkennerpaar. Apples Dokumentation zum Delegate des Gestenerkenners
Überprüfe die Interaktion anhand einer kleinen Verhaltensmatrix
Teste den genauen Button-Stil, die Hierarchie und die macOS-Versionen, die deine App unterstützt. Die folgenden Prüfungen trennen Probleme bei der Erkennung von Fehlern in der Bewegungsberechnung und zeigen, ob die gemeinsam genutzte Fläche dem beabsichtigten Interaktionsvertrag entspricht.
| Aktion | Zu prüfen |
|---|---|
| Jedes Item ohne Ziehen anklicken | Sein Auswahlstatus ändert sich; das Panel bleibt stehen |
| Die Kopfzeile über die Schwelle hinaus ziehen | Das Panel folgt dem Zeiger und behält seine Endposition bei |
| In der gemeinsamen Variante von einem Item aus ziehen | Die Bewegung des übergeordneten Elements funktioniert; prüfe, ob der Button ebenfalls aktiviert wird, und bestätige, dass dies zulässig ist |
| Zwei Ziehvorgänge nacheinander ausführen | Der zweite beginnt an der zuerst übernommenen Position, ohne jede Zwischenverschiebung aufzuaddieren |
| Einen aktiven Ziehvorgang unterbrechen | Die temporäre Verschiebung wird zurückgesetzt; keine unvollständige Bewegung wird übernommen |
| Die Variante mit Ziehgriff ausprobieren | Items lassen sich unabhängig aktivieren; die Bewegung beginnt an der Kopfzeile |
| Item-Buttons über die Tastaturnavigation aktivieren | Die Auswahl funktioniert ohne Ziehen mit dem Zeiger |
Wenn sich leere Panel-Flächen korrekt ziehen lassen, Item-Bereiche aber nicht, untersuche die untergeordneten Steuerelemente und die Gestenzusammensetzung. Wenn Klicks nicht mehr funktionieren, prüfe Gestenmasken, Priorität und Trefferbereiche. Wenn das Panel beschleunigt oder springt, prüfe die aufsummierte Verschiebung und den gewählten Koordinatenraum, bevor du weitere Erkenner hinzufügst.
Das Beispiel begrenzt die Bewegung absichtlich nicht; das Panel kann sich daher über die sichtbare Fläche hinaus bewegen. Der Button Reset panel setzt seine Position zurück. Füge für einen fertigen Arbeitsbereich mit Begrenzung eine ausdrückliche Positionsbeschränkung anhand der Maße der Fläche und des Panels hinzu. Behalte dieselbe Zuständigkeitsstruktur bei: Untergeordnete Elemente fordern Aktionen an, das übergeordnete Element verwaltet die Bewegung, und der Interaktionsvertrag bestimmt, ob sich ihre Erkennungsbereiche überschneiden.
Quellen
- simultaneousGesture(_:including:) — Apple
- Gestures — Apple
- coordinateSpace(_:) — Apple
- DragGesture — Apple
- updating(_:body:) — Apple
- translation — Apple
- State — Apple
- Closures — The Swift Programming Language, Swift project
- contentShape(_:eoFill:) — Apple
- GestureMask — Apple
- highPriorityGesture(_:including:) — Apple
- Button — Apple
- hitTest(_:) — Apple
- mouseDown(with:) — Apple
- gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) — Apple