Aggiungi il trascinamento del genitore con simultaneousGesture(_:including:) per consentirgli di partecipare insieme alle interazioni dei figli. Mantieni le azioni di clic nei pulsanti figli, imposta una soglia di movimento per il trascinamento e decidi esplicitamente se la selezione può avvenire insieme al trascinamento.

Questa guida realizza un pannello spostabile che contiene tre pulsanti di elementi sintetici. L’azione richiesta al lettore è specifica: fare clic su un elemento lo seleziona, mentre trascinare il pannello ne cambia la posizione all’interno della stessa app Mac. L’esempio consente di combinare selezione e spostamento; una variante con maniglia separata offre aree d’interazione distinte quando è preferibile.

Scegliere il contratto d’interazione prima di modificare la priorità dei gesti

Apple documenta simultaneousGesture(_:including:) come un modo per aggiungere un gesto con la stessa priorità dei gesti esistenti. Il suo esempio mostra sia il gestore di tocco di un elemento figlio, sia il gestore di tocco del contenitore che lo racchiude, rispondere a un clic. Questo è il meccanismo di composizione pertinente quando il genitore deve partecipare senza prevalere deliberatamente sul figlio. La documentazione Apple sui gesti simultanei

Il riconoscimento simultaneo, tuttavia, non esprime di per sé la regola «il trascinamento deve annullare ogni azione del figlio». L’esempio di Apple non stabilisce nemmeno che tutti gli stili di pulsante e tutti i contenitori gestiscano allo stesso modo un pulsante e un trascinamento. Considera la partecipazione ai gesti e il comportamento dell’app come decisioni separate.

In questo pannello, il genitore gestisce l’offset del pannello e l’elemento selezionato. Ogni figlio contiene un pulsante che richiede la selezione tramite una closure. La richiesta di selezione resta valida anche se la stessa interazione del puntatore sposta il pannello. Se il comportamento richiesto vieta l’attivazione durante il movimento, usa la variante con maniglia separata descritta sotto, anziché affidarti a un ordine delle callback non documentato.

Usa un Button per l’azione del figlio. Apple raccomanda esplicitamente i pulsanti quando un’interazione richiede il comportamento di un pulsante, comprese le funzionalità standard di accessibilità. Una forma personalizzata può restare l’etichetta del pulsante: non deve diventare un semplice gesto di tocco. La documentazione Apple sui gesti

Creare un pannello completo e spostabile con pulsanti figli

Crea un’app SwiftUI per macOS in Xcode, imposta la versione minima di macOS a 14 o successiva e sostituisci l’app e la vista del contenuto generate con il seguente file singolo. Mantieni un solo punto d’ingresso dell’app nel target. Il requisito di macOS 14 copre il moderno modificatore coordinateSpace(_:) usato qui. La documentazione Apple sugli spazi di coordinate

Tutti gli elementi e lo stato esistono in memoria. L’esempio sposta l’intero pannello, compresi i pulsanti, all’interno di un’area fissa. Non implementa il trasferimento degli elementi né sposta la finestra dell’applicazione.

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)
    }
}

Questo programma è stato esaminato alla luce della documentazione API recuperata, ma qui non è stato compilato né testato interattivamente: in questo ambiente non sono disponibili un compilatore Swift o un runtime dell’interfaccia utente macOS. Compilalo in Xcode ed esegui i controlli seguenti sulle versioni di macOS supportate.

Perché il trascinamento ha una soglia diversa da zero

DragGesture espone una distanza minima di movimento da superare perché il gesto abbia effetto. Il valore illustrativo 8 concede ai normali clic una certa tolleranza di movimento prima che il genitore inizi a trascinare. È una scelta dell’applicazione, non una soglia prescritta da Apple né una politica completa per distinguere clic e trascinamento. Un trascinamento a distanza zero elimina questa specifica distinzione. La documentazione Apple su DragGesture

Perché il movimento temporaneo e quello confermato sono separati

Durante il riconoscimento, updating(_:body:) scrive la traslazione corrente in liveTranslation. Apple documenta che lo stato del gesto viene reimpostato al termine o all’annullamento del gesto. L’esempio conferma l’offset finale solo in onEnded, così un annullamento scarta l’anteprima non confermata invece di lasciare una traslazione temporanea obsoleta. La documentazione Apple su updating

La posizione visualizzata è la somma dell’offset confermato e della traslazione corrente. Apple definisce translation come lo spostamento totale dall’inizio del trascinamento, quindi aggiungerlo ripetutamente all’offset confermato a ogni variazione conterebbe il movimento più volte. A titolo illustrativo, un offset orizzontale confermato di 40 e una traslazione corrente di 12 producono un offset visualizzato di 52; al termine del trascinamento, 52 viene confermato una sola volta. La documentazione Apple su translation

Lo spazio di coordinate denominato appartiene all’area fissa che circonda il pannello mobile. In questo modo, il calcolo del movimento fa riferimento a una regione esplicita. I pulsanti figli non mantengono copie proprie della posizione del pannello.

Perché la selezione resta gestita dal genitore

Il genitore memorizza selectedID e fornisce a ogni figlio lo stato di selezione corrente e una closure per richiedere la selezione. Questo segue le indicazioni di Apple: mantenere lo stato nella vista più in alto che ne ha bisogno, quindi passare valori o binding lungo la gerarchia. La documentazione Apple su State

La closure acquisisce l’elemento corrente e richiede un aggiornamento dello stato del genitore. Swift supporta le closure che acquisiscono valori dal contesto circostante; per implementare questa callback non è necessario copiare o inoltrare alcun evento AppKit. The Swift Programming Language: Closures

Capire le aree di interazione e le maschere dei gesti

contentShape(Rectangle()) definisce la forma usata per il rilevamento delle interazioni. Nell’etichetta del pulsante, si trova dopo la spaziatura interna e il frame, così da rendere esplicita la regione rettangolare prevista per l’etichetta. Sul genitore, definisce la forma d’interazione del pannello. Non imposta la priorità dei riconoscitori né richiede che due gesti collaborino. La documentazione Apple su contentShape

La maschera fornita a un modificatore di gesto determina quali gesti restano abilitati. Queste opzioni sono facili da interpretare male: subviews significa che il gesto aggiunto al genitore è disabilitato, non che collabori automaticamente con i discendenti. La documentazione Apple su GestureMask

Maschera Effetto documentato Idoneità in questo caso
.all Abilita il gesto aggiunto e gli altri gesti sulla vista e sulle sue sottoviste Punto di partenza per una partecipazione condivisa
.gesture Abilita il gesto aggiunto e disabilita i gesti nella gerarchia delle sottoviste Incompatibile con il mantenimento dei gesti dei figli
.subviews Abilita i gesti delle sottoviste e disabilita il gesto aggiunto Disabilita il trascinamento del genitore
.none Disabilita i gesti nella gerarchia delle sottoviste, compreso il gesto aggiunto Disabilita l’interazione prevista

Evita di considerare highPriorityGesture una soluzione generale quando mancano le callback del trascinamento del genitore. Il suo scopo documentato è dare al gesto associato una precedenza maggiore. L’esempio di Apple mostra il genitore che prevale sul tocco del figlio. Sceglilo quando questa precedenza è il comportamento previsto. La documentazione Apple sui gesti ad alta priorità

Usare una maniglia di trascinamento separata quando l’attivazione deve restare distinta

Per un pannello i cui pulsanti non devono condividere l’area di trascinamento, mantieni lo stesso stato e lo stesso calcolo del movimento, ma associa pan solo all’intestazione. Rimuovi .simultaneousGesture(pan, including: .all) dal pannello genitore.

Sostituisci l’espressione dell’intestazione esistente con questo frammento nel programma completo riportato sopra. Usa la proprietà pan, già definita; non servono altri prerequisiti.

Text("Drag panel")
    .font(.headline)
    .frame(width: 244, height: 32)
    .contentShape(Rectangle())
    .gesture(pan)

L’intestazione e i pulsanti degli elementi occupano ora regioni distinte, e i pulsanti non hanno più il trascinamento del pannello associato a un antenato. Iniziare su un elemento attiva un’interazione con il pulsante; iniziare sull’intestazione permette di spostare il pannello. Questo approccio è utile quando la partecipazione condivisa renderebbe ambiguo il risultato.

Mantieni il pulsante nativo quando il figlio avvia un’azione. Apple descrive Button come un’azione associata a un’etichetta e consente etichette composte da viste personalizzate, come quelle usate per gli elementi colorati sintetici. Sostituirlo con un gestore di puntatore creato a mano richiederebbe di ricreare un comportamento già fornito dal controllo. La documentazione Apple su Button

Preservare la gestione degli eventi AppKit ai confini del framework

Se il pannello include una vista AppKit, analizza separatamente il suo hit testing e la composizione dei gesti SwiftUI. Il metodo AppKit hitTest(_:) restituisce il discendente corrispondente più profondo e aiuta la finestra a determinare il destinatario di un evento mouse-down. Il punto ricevuto è espresso nelle coordinate della superview della vista. La documentazione Apple sull’hit testing in AppKit

Restituire il genitore dall’hit testing cambia il destinatario iniziale; non equivale a consentire il riconoscimento simultaneo da parte di un figlio e del genitore. Allo stesso modo, restituire nil dall’hit test di una sovrapposizione la esclude dalla valutazione: non rende la sovrapposizione un osservatore passivo dell’intera interazione del figlio.

Apple documenta che l’implementazione predefinita di mouseDown(with:) inoltra il messaggio al responder successivo. Questo è il comportamento della catena dei responder, non la garanzia che ogni antenato riceva una copia dell’evento dopo che un controllo lo ha gestito. Collega l’attivazione del controllo e il movimento del pannello tramite callback esplicite o riconoscitori di cui hai il controllo. La documentazione Apple su mouse-down

Quando possiedi due riconoscitori di gesti AppKit e hai bisogno del riconoscimento simultaneo, AppKit fornisce gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:). Apple documenta che restituire true consente alla coppia di riconoscere i gesti insieme; restituire false non garantisce l’esclusione se il delegato dell’altro riconoscitore la consente. Limita questa regola alla coppia di riconoscitori prevista. La documentazione Apple sul delegato dei riconoscitori

Verificare l’interazione con una piccola matrice di comportamento

Prova esattamente lo stile del pulsante, la gerarchia e le versioni di macOS supportate dall’app. I controlli seguenti permettono di distinguere i problemi di riconoscimento dagli errori nel calcolo del movimento e chiariscono se la superficie condivisa corrisponde al contratto previsto.

Azione Cosa verificare
Fare clic su ciascun elemento senza trascinare Il suo stato di selezione cambia; il pannello resta fermo
Trascinare l’intestazione oltre la soglia Il pannello segue il puntatore e mantiene la posizione finale
Trascinare da un elemento nella variante condivisa Il pannello si sposta; stabilisci se si attiva anche il pulsante e verifica che sia accettabile
Eseguire due trascinamenti consecutivi Il secondo parte dalla posizione confermata dal primo senza accumulare ogni traslazione intermedia
Interrompere un trascinamento in corso La traslazione temporanea viene cancellata; non viene confermato alcun movimento incompleto
Provare la variante con maniglia Gli elementi si attivano in modo indipendente; il movimento inizia dall’intestazione
Attivare i pulsanti degli elementi tramite la navigazione da tastiera La selezione funziona senza richiedere un trascinamento con il puntatore

Se gli spazi vuoti del pannello si trascinano correttamente ma le aree degli elementi no, esamina i controlli figli e la composizione dei gesti. Se i clic non hanno effetto, controlla le maschere dei gesti, le precedenze e le aree di interazione. Se il pannello accelera o salta, verifica l’accumulo delle traslazioni e lo spazio di coordinate scelto prima di aggiungere altri riconoscitori.

L’esempio lascia intenzionalmente il movimento senza limiti, quindi il pannello può spostarsi oltre l’area visibile; il pulsante Reset panel ne ripristina la posizione. Per un’area di lavoro definitiva con limiti, aggiungi un vincolo esplicito sulla posizione basato sulle dimensioni dell’area e del pannello. Mantieni la stessa struttura di responsabilità: i figli richiedono azioni, il genitore gestisce il movimento e il contratto d’interazione stabilisce se le aree di riconoscimento si sovrappongono.

Fonti