Dołącz przeciąganie widoku nadrzędnego za pomocą simultaneousGesture(_:including:), aby mogło działać równolegle z interakcjami elementów podrzędnych. Umieść akcje kliknięcia w przyciskach podrzędnych, ustaw próg ruchu dla przeciągania i jasno zdecyduj, czy zaznaczenie może towarzyszyć przeciąganiu.

W tym przewodniku tworzymy ruchomy panel zawierający trzy przykładowe przyciski elementów. Zadanie użytkownika jest konkretne: kliknięcie elementu zaznacza go, a przeciągnięcie panelu zmienia jego położenie w obrębie tej samej aplikacji Mac. Przykład pozwala na jednoczesne zaznaczanie i przesuwanie; alternatywny wariant z osobnym uchwytem zapewnia rozdzielone obszary interakcji, jeśli takie rozwiązanie jest preferowane.

Wybierz zasady interakcji przed zmianą priorytetu gestów

Apple opisuje simultaneousGesture(_:including:) jako dodanie gestu o takim samym priorytecie jak istniejące gesty. W przykładzie obsługa stuknięcia przez element podrzędny i obsługa stuknięcia przez otaczający go kontener reagują na kliknięcie. To odpowiedni mechanizm łączenia gestów, gdy widok nadrzędny ma uczestniczyć w interakcji bez celowego nadawania mu pierwszeństwa przed elementem podrzędnym. Dokumentacja Apple dotycząca gestów równoczesnych

Rozpoznawanie równoczesne samo w sobie nie wyraża jednak zasady „przeciąganie musi anulować każdą akcję elementu podrzędnego”. Przykład Apple nie dowodzi też, że każdy styl przycisku i każdy kontener w identyczny sposób rozstrzyga interakcje przycisku i przeciągania. Udział gestów i zachowanie aplikacji traktuj jako osobne decyzje.

W tym panelu widok nadrzędny przechowuje przesunięcie panelu i zaznaczony element. Każdy element podrzędny ma własny przycisk, który zgłasza żądanie zaznaczenia za pomocą domknięcia. Żądanie zaznaczenia pozostaje dozwolone nawet wtedy, gdy ta sama interakcja wskaźnikiem przesuwa panel. Jeśli wymagane zachowanie zabrania aktywacji podczas ruchu, użyj poniższego wariantu z osobnym uchwytem zamiast polegać na nieudokumentowanej kolejności wywołań zwrotnych.

Użyj elementu Button dla akcji podrzędnej. Apple wyraźnie zaleca przyciski, gdy interakcja wymaga zachowania przycisku, w tym standardowej obsługi dostępności. Niestandardowy kształt może pozostać etykietą przycisku; nie musi być samodzielnym gestem stuknięcia. Dokumentacja Apple dotycząca gestów

Zbuduj kompletny ruchomy panel z przyciskami podrzędnymi

Utwórz w Xcode aplikację SwiftUI dla macOS, ustaw minimalną obsługiwaną wersję macOS 14 lub nowszą i zastąp wygenerowane pliki aplikacji oraz widoku zawartości poniższym pojedynczym plikiem. W celu kompilacji pozostaw tylko jeden punkt wejścia aplikacji. Wymóg macOS 14 wynika z użytego tutaj nowoczesnego modyfikatora coordinateSpace(_:). Dokumentacja Apple dotycząca przestrzeni współrzędnych

Wszystkie elementy i stan istnieją w pamięci. Przykład przesuwa cały panel wraz z przyciskami w obrębie nieruchomej planszy. Nie implementuje przenoszenia elementów ani przesuwania okna aplikacji.

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

Ten program sprawdzono pod kątem zgodności z pobraną dokumentacją API, ale nie został tutaj skompilowany ani przetestowany interaktywnie: w tym środowisku nie ma kompilatora Swift ani środowiska uruchomieniowego interfejsu macOS. Skompiluj go w Xcode i wykonaj poniższe sprawdzenia na obsługiwanych wersjach macOS.

Dlaczego gest przeciągania ma niezerowy próg

DragGesture udostępnia minimalną odległość ruchu, po której gest zostaje rozpoznany. Przykładowa wartość 8 daje zwykłym kliknięciom pewien margines ruchu, zanim widok nadrzędny zacznie przeciąganie. To wybór aplikacji, a nie próg zalecany przez Apple ani kompletna reguła rozróżniania kliknięcia od przeciągnięcia. Przeciąganie z odległością zerową eliminuje to konkretne rozróżnienie. Dokumentacja Apple dotycząca DragGesture

Dlaczego ruch tymczasowy i zatwierdzony są rozdzielone

Podczas rozpoznawania gestu updating(_:body:) zapisuje bieżące przesunięcie w liveTranslation. Apple dokumentuje, że stan gestu jest resetowany po jego zakończeniu lub anulowaniu. W przykładzie końcowe przesunięcie jest zatwierdzane tylko w onEnded, więc anulowanie odrzuca niezatwierdzony podgląd zamiast pozostawiać nieaktualne przesunięcie tymczasowe. Dokumentacja Apple dotycząca aktualizowania

Wyświetlane położenie to zatwierdzone przesunięcie plus bieżące przesunięcie. Apple definiuje translation jako całkowite przemieszczenie od początku przeciągania, więc dodawanie go przy każdej zmianie do zatwierdzonego przesunięcia prowadziłoby do zawyżenia ruchu. Dla przykładu zatwierdzone przesunięcie poziome 40 i bieżące przesunięcie 12 dają wyświetlane przesunięcie 52; po zakończeniu przeciągania wartość 52 zostaje zatwierdzona jeden raz. Dokumentacja Apple dotycząca przesunięcia

Nazwana przestrzeń współrzędnych należy do nieruchomej planszy otaczającej ruchomy panel. Dzięki temu obliczenia ruchu odnoszą się do jawnie określonego obszaru odniesienia. Przyciski podrzędne nie przechowują własnych kopii położenia panelu.

Dlaczego zaznaczenie pozostaje w widoku nadrzędnym

Widok nadrzędny przechowuje selectedID i przekazuje każdemu elementowi podrzędnemu informację, czy jest aktualnie zaznaczony, oraz domknięcie do obsługi zaznaczenia. Jest to zgodne ze wskazówkami Apple, by przechowywać stan w najwyższym widoku, który go potrzebuje, a następnie przekazywać wartości lub powiązania w dół hierarchii. Dokumentacja Apple dotycząca State

Domknięcie przechwytuje bieżący element i zgłasza żądanie aktualizacji stanu widoku nadrzędnego. Swift obsługuje domknięcia przechwytujące wartości z otaczającego kontekstu; do zaimplementowania tego wywołania zwrotnego nie trzeba kopiować ani przekazywać dalej żadnego zdarzenia AppKit. Język programowania Swift: Domknięcia

Zrozum obszary trafień i maski gestów

contentShape(Rectangle()) definiuje kształt używany do trafień. W etykiecie przycisku znajduje się za wypełnieniem i ramką, dzięki czemu jawnie określa zamierzony prostokątny obszar etykiety. W widoku nadrzędnym definiuje kształt interakcji panelu. Nie ustawia priorytetu rozpoznawania ani nie wymusza współpracy dwóch gestów. Dokumentacja Apple dotycząca kształtu zawartości

Maska przekazywana do modyfikatora gestu określa, które gesty pozostają włączone. Te opcje łatwo błędnie zinterpretować: subviews oznacza, że dodany gest widoku nadrzędnego jest wyłączony, a nie że automatycznie współpracuje z elementami podrzędnymi. Dokumentacja Apple dotycząca GestureMask

Maska Udokumentowany skutek Przydatność w tym przypadku
.all Włącza dodany gest i pozostałe gesty widoku oraz jego widoków podrzędnych Punkt wyjścia dla wspólnego udziału
.gesture Włącza dodany gest i wyłącza gesty w hierarchii widoków podrzędnych Nie zachowuje gestów elementów podrzędnych
.subviews Włącza gesty widoków podrzędnych i wyłącza dodany gest Wyłącza przeciąganie widoku nadrzędnego
.none Wyłącza gesty w hierarchii widoków podrzędnych, w tym dodany gest Wyłącza zamierzoną interakcję

Nie traktuj highPriorityGesture jako ogólnego rozwiązania braku wywołań zwrotnych przeciągania widoku nadrzędnego. Jego udokumentowanym celem jest nadanie dołączonemu gestowi wyższego priorytetu. Przykład Apple pokazuje, że widok nadrzędny zyskuje pierwszeństwo przed stuknięciem w element podrzędny. Wybierz to rozwiązanie, gdy takie pierwszeństwo jest zamierzonym zachowaniem. Dokumentacja Apple dotycząca gestów o wysokim priorytecie

Użyj osobnego uchwytu do przeciągania, gdy aktywacja ma być oddzielona

Jeśli przyciski panelu nie powinny współdzielić obszaru przeciągania, zachowaj ten sam stan i sposób obliczania ruchu, ale dołącz pan tylko do nagłówka. Usuń .simultaneousGesture(pan, including: .all) z panelu nadrzędnego.

Zastąp istniejące wyrażenie nagłówka tym fragmentem wewnątrz kompletnego programu powyżej. Korzysta on z już zdefiniowanej właściwości pan; pozostałe wymagania wstępne się nie zmieniają.

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

Nagłówek i przyciski elementów zajmują teraz osobne obszary, a do przodka przycisków nie jest już dołączone przeciąganie panelu. Rozpoczęcie interakcji na elemencie uruchamia przycisk; rozpoczęcie jej na nagłówku umożliwia przesunięcie panelu. Ten projekt jest przydatny, gdy wspólny udział prowadziłby do niejednoznacznego rezultatu.

Jeśli element podrzędny uruchamia akcję, pozostaw natywny przycisk. Apple opisuje Button jako akcję i etykietę oraz dopuszcza niestandardowe widoki etykiet, tak jak w przypadku przykładowych kolorowych elementów. Zastąpienie przycisku własną obsługą wskaźnika wymagałoby odtworzenia zachowania, które kontrolka już zapewnia. Dokumentacja Apple dotycząca Button

Zachowaj obsługę zdarzeń AppKit na granicach frameworków

Jeśli panel zawiera widok AppKit, diagnozuj jego testowanie trafień osobno od kompozycji gestów SwiftUI. hitTest(_:) z AppKit zwraca najgłębszy pasujący widok potomny i pomaga oknu określić odbiorcę zdarzenia naciśnięcia przycisku myszy. Jego punkt wejściowy jest wyrażony we współrzędnych widoku nadrzędnego. Dokumentacja Apple dotycząca testowania trafień w AppKit

Zwrócenie widoku nadrzędnego podczas testowania trafień zmienia początkowy cel zdarzenia; nie jest odpowiednikiem równoczesnego rozpoznawania gestów przez widok podrzędny i nadrzędny. Podobnie zwrócenie nil w teście trafień nakładki usuwa tę nakładkę z rozważań — nie czyni jej biernym obserwatorem pełnej interakcji elementu podrzędnego.

AppKit dokumentuje, że domyślna implementacja mouseDown(with:) przekazuje komunikat do następnego elementu łańcucha odpowiedzialności. Jest to zachowanie łańcucha odpowiedzialności, a nie obietnica, że każdy element nadrzędny niezależnie otrzyma kopię zdarzenia po jego obsłużeniu przez kontrolkę. Połącz aktywację kontrolki i ruch panelu za pomocą jawnych wywołań zwrotnych lub rozpoznawaczy, którymi zarządzasz. Dokumentacja Apple dotycząca naciśnięcia przycisku myszy

Gdy zarządzasz dwoma rozpoznawaczami gestów AppKit i potrzebujesz jednoczesnego rozpoznawania, AppKit udostępnia gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:). Apple dokumentuje, że zwrócenie true pozwala parze rozpoznawać gesty jednocześnie; zwrócenie false nie gwarantuje ich wzajemnego wykluczenia, jeśli delegat drugiego rozpoznawacza na nie zezwoli. Ogranicz tę regułę do zamierzonej pary rozpoznawaczy. Dokumentacja delegata rozpoznawacza Apple

Sprawdź interakcję za pomocą niewielkiej macierzy zachowań

Przetestuj dokładny styl przycisku, hierarchię i wersje macOS obsługiwane przez aplikację. Poniższe sprawdzenia pozwalają oddzielić problemy z rozpoznawaniem od obliczeń ruchu oraz ustalić, czy współdzielona powierzchnia spełnia założenia interakcji.

Działanie Co sprawdzić
Kliknij każdy element bez przeciągania Zmienia się jego stan zaznaczenia; panel pozostaje nieruchomy
Przeciągnij nagłówek poza próg Panel podąża za wskaźnikiem i zachowuje końcowe położenie
Przeciągnij element w wersji ze wspólnym obszarem Panel się przesuwa; sprawdź, czy aktywuje się też przycisk i czy jest to akceptowalne
Wykonaj dwa kolejne przeciągnięcia Drugie rozpoczyna się od pierwszego zatwierdzonego położenia, bez kumulowania każdej pośredniej wartości przesunięcia
Przerwij aktywne przeciąganie Przesunięcie tymczasowe zostaje wyczyszczone; niedokończony ruch nie zostaje zatwierdzony
Wypróbuj wariant z uchwytem Elementy aktywują się niezależnie; przesuwanie rozpoczyna się od nagłówka
Aktywuj przyciski elementów za pomocą nawigacji klawiaturą Zaznaczenie działa bez konieczności przeciągania wskaźnikiem

Jeśli puste miejsce panelu można przeciągać prawidłowo, ale obszary elementów nie reagują, sprawdź kontrolki podrzędne i kompozycję gestów. Jeśli kliknięcia są ignorowane, sprawdź maski gestów, priorytety i obszary trafień. Jeśli panel przyspiesza albo przeskakuje, sprawdź kumulowanie przesunięcia i wybraną przestrzeń współrzędnych, zanim dodasz kolejne rozpoznawacze.

Przykład celowo nie ogranicza ruchu, więc panel może wyjść poza widoczną planszę; przycisk Reset panel przywraca jego położenie. W gotowym obszarze roboczym z ograniczeniami dodaj jawne ograniczenie położenia oparte na wymiarach planszy i panelu. Zachowaj tę samą strukturę odpowiedzialności: elementy podrzędne zgłaszają akcje, widok nadrzędny zarządza ruchem, a zasady interakcji określają, czy obszary rozpoznawania się pokrywają.

Źródła