Aby sprawić, by niestandardowy suwak przeciągania w SwiftUI zatrzaskiwał się na dyskretnych wartościach, przekształć lokalne położenie wskaźnika w ograniczony ułamek, zaokrąglij go do całkowitego indeksu i rysuj uchwyt na podstawie tego indeksu. Ten przykład dla macOS pozwala wybrać jeden z pięciu rozmiarów koła i zatwierdza wybór po zwolnieniu przycisku myszy.

Wybierz sposób interakcji, zanim napiszesz gest

Zadanie czytelnika jest proste: dodać do aplikacji na Maca niestandardowy poziomy selektor rozmiaru. Dostępne rozmiary to 20, 40, 60, 80 i 100 punktów. Przeciąganie podgląda zaokrąglony wybór; zwolnienie przycisku stosuje ten rozmiar do przykładowego koła. Wszystkie dane pozostają w pamięci.

Najpierw zdecyduj, czy niestandardowe rysowanie jest konieczne. Standardowy Slider firmy Apple obsługuje już dyskretne przyrosty za pomocą parametru step i aktualizuje powiązaną wartość podczas przesuwania uchwytu. W przypadku typowego selektora rozmiaru zakres 20...100 z krokiem 20 bezpośrednio określa te opcje. Użyj tego obsługiwanego elementu, jeśli jego wygląd i sposób działania spełniają wymagania. Dokumentacja Slider firmy Apple

Poniższa niestandardowa implementacja przydaje się, gdy chcesz kontrolować tor, znaczniki podziałki, uchwyt i zachowanie po zwolnieniu przycisku. Celowo używa całkowitego indeksu wyboru. Wyświetlany rozmiar pochodzi z tablicy, więc model nie może przypadkowo przyjąć pośredniego rozmiaru, takiego jak 57.3.

Ten przykład opiera się na trzech decyzjach:

  • Znaczniki są rozmieszczone w równych odstępach, a większe rozmiary znajdują się po prawej stronie.
  • Zarówno uchwyt, jak i etykieta wartości kontrolki zatrzaskują się podczas przeciągania.
  • Wybrany rozmiar w widoku nadrzędnym zmienia się po zwolnieniu przycisku; koło zachowuje wcześniej zatwierdzony rozmiar podczas podglądu.

Przekształć położenie wskaźnika w dyskretny indeks

Zachowaj zgodność geometrii wejściowej i geometrii rysowania. W tym przypadku kontrolka ma szerokość 336 punktów, a promień uchwytu wynosi 12 punktów. Jego środek porusza się więc od 12 do 324, co daje użyteczną długość toru równą 312 punktów. Użycie pełnej szerokości w obliczeniach wejściowych sprawiłoby, że położenie wskaźnika i skrajnych znaczników byłyby niespójne.

Mapowanie obejmuje cztery operacje: odejmij lewy margines, podziel przez użyteczną długość toru, ogranicz wynik do zakresu 0...1 i pomnóż przez liczbę przedziałów, a następnie zaokrąglij. Pięć opcji daje cztery przedziały. Odwrotne mapowanie umieszcza wybrany uchwyt przy odpowiadającym mu znaczniku.

Poniższa funkcja pomocnicza należy do kompletnej aplikacji złożonej w następnej sekcji. Zastosowana reguła zaokrąglania wybiera wyższy indeks dokładnie w połowie przedziału, ponieważ indeksy znormalizowane są nieujemne. Swift opisuje .toNearestOrAwayFromZero jako zaokrąglanie do najbliższej wartości, a w przypadku remisu — od zera. Dokumentacja rounded(_:) firmy Apple

import SwiftUI

enum SnapGeometry {
    static func index(
        at x: CGFloat,
        width: CGFloat,
        inset: CGFloat,
        count: Int
    ) -> Int {
        guard count > 1, x.isFinite, width > 2 * inset else {
            return 0
        }
        let fraction = min(max((x - inset) / (width - 2 * inset), 0), 1)
        return Int(
            (fraction * CGFloat(count - 1))
                .rounded(.toNearestOrAwayFromZero)
        )
    }

    static func position(
        for index: Int,
        width: CGFloat,
        inset: CGFloat,
        count: Int
    ) -> CGFloat {
        guard count > 1 else { return width / 2 }
        return inset + CGFloat(index) / CGFloat(count - 1)
            * (width - 2 * inset)
    }
}

Poniższe przykładowe dane wejściowe pokazują zachowanie na granicach dla ustalonej geometrii. Ograniczenie wartości sprawia, że przeciągnięcie poza dowolny koniec nadal wskazuje prawidłową opcję; interakcja nie jest odrzucana.

Współrzędna x wskaźnika Wybrany indeks Rozmiar w punktach
-10 0 20
12 0 20
51 1 40
90 1 40
168 2 60
324 4 100
350 4 100

Przy 51 wskaźnik znajduje się dokładnie w połowie między pierwszymi dwoma znacznikami, więc reguła wybiera indeks 1. Te deterministyczne obliczenia sprawdziłem w Pythonie. W środowisku badawczym nie było dostępnego kompilatora Swift, dlatego poniższy przykład aplikacji SwiftUI został przejrzany, ale nie został tu skompilowany ani uruchomiony.

Zbuduj kontrolkę macOS i widok, który nią zarządza

Utwórz w Xcode projekt aplikacji macOS oparty na SwiftUI, z minimalnym wymaganiem macOS 13 lub nowszym. Zastąp wygenerowaną deklarację aplikacji i widok zawartości poniższym kodem, a następnie dodaj poprzednią funkcję pomocniczą do tego samego pliku Swift. Funkcja pomocnicza i dwa następne bloki składają się na kompletny program; pozostaw tylko jedną deklarację aplikacji @main.

Widok nadrzędny przechowuje zatwierdzony indeks w @State. Kontrolka otrzymuje @Binding, który łączy ją z tym źródłem prawdy bez tworzenia drugiego trwałego wyboru. Jest to zgodne z opisanym przez Apple wzorcem zarządzania własnością dla powiązań. Dokumentacja Binding firmy Apple

@main
struct SnapSizeApp: App {
    var body: some Scene {
        WindowGroup {
            SizeDemo()
        }
    }
}

struct SizeDemo: View {
    private let sizes = [20, 40, 60, 80, 100]
    @State private var selectedIndex = 2

    var body: some View {
        VStack(spacing: 24) {
            Text("Circle size")
                .font(.headline)

            Circle()
                .fill(Color.accentColor)
                .frame(
                    width: CGFloat(sizes[selectedIndex]),
                    height: CGFloat(sizes[selectedIndex])
                )
                .frame(width: 120, height: 120)

            DiscreteSizeControl(
                sizes: sizes,
                selection: $selectedIndex
            )

            Text("Applied size: \(sizes[selectedIndex]) pt")
        }
        .padding(32)
        .frame(minWidth: 420, minHeight: 340)
    }
}

Kontrolka używa @GestureState do przechowywania tymczasowego podglądu. Apple dokumentuje, że stan gestu wraca do wartości początkowej, gdy gest staje się nieaktywny. W tym przypadku powrót do nil powoduje ponowne wyświetlenie zatwierdzonej wartości powiązania, więc tymczasowego wyboru nie trzeba ręcznie czyścić w osobnej zmiennej trwałego stanu. Dokumentacja GestureState firmy Apple

struct DiscreteSizeControl: View {
    let sizes: [Int]
    @Binding var selection: Int
    @GestureState private var previewIndex: Int? = nil

    private let trackWidth: CGFloat = 336
    private let radius: CGFloat = 12

    private var shownIndex: Int {
        previewIndex ?? selection
    }

    private func snappedIndex(at x: CGFloat) -> Int {
        SnapGeometry.index(
            at: x, width: trackWidth,
            inset: radius, count: sizes.count
        )
    }

    private func xPosition(for index: Int) -> CGFloat {
        SnapGeometry.position(
            for: index, width: trackWidth,
            inset: radius, count: sizes.count
        )
    }

    private func moveSelection(by amount: Int) {
        guard previewIndex == nil else { return }
        selection = min(max(selection + amount, 0), sizes.count - 1)
    }

    var body: some View {
        VStack(spacing: 10) {
            Text("Selected size: \(sizes[shownIndex]) pt")
                .monospacedDigit()

            ZStack(alignment: .topLeading) {
                Capsule()
                    .fill(Color.secondary.opacity(0.25))
                    .frame(width: trackWidth - 2 * radius, height: 4)
                    .position(x: trackWidth / 2, y: 20)

                ForEach(sizes.indices, id: \.self) { index in
                    Circle()
                        .fill(Color.secondary)
                        .frame(width: 5, height: 5)
                        .position(x: xPosition(for: index), y: 20)
                }

                Circle()
                    .fill(Color.accentColor)
                    .frame(width: 2 * radius, height: 2 * radius)
                    .position(x: xPosition(for: shownIndex), y: 20)
            }
            .frame(width: trackWidth, height: 40)
            .contentShape(Rectangle())
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .updating($previewIndex) { value, state, _ in
                        state = snappedIndex(at: value.location.x)
                    }
                    .onEnded { value in
                        selection = snappedIndex(at: value.location.x)
                    }
            )
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("Circle size")
            .accessibilityValue("\(sizes[shownIndex]) points")
            .accessibilityAdjustableAction { direction in
                switch direction {
                case .increment:
                    moveSelection(by: 1)
                case .decrement:
                    moveSelection(by: -1)
                @unknown default:
                    break
                }
            }

            HStack {
                Button("Smaller") { moveSelection(by: -1) }
                    .disabled(selection == 0 || previewIndex != nil)
                Button("Larger") { moveSelection(by: 1) }
                    .disabled(
                        selection == sizes.count - 1 || previewIndex != nil
                    )
            }
        }
    }
}

Ten przykład ma jawne założenia: tablica rozmiarów nie jest pusta, zatwierdzony indeks jest prawidłowy, a tablica pozostaje niezmieniona podczas interakcji. Widok nadrzędny w przykładzie spełnia wszystkie trzy. Zabezpieczona wartość zwracana przez funkcję pomocniczą obsługującą geometrię nie sprawia, że pusta tablica jest prawidłowa dla kontrolki, ponieważ etykieta nadal odwołuje się do jej elementu. Jeśli opcje pochodzą ze zmiennego modelu, sprawdź kolekcję i wybór przed renderowaniem oraz określ, co ma się stać, gdy opcje zmienią się podczas przeciągania.

Przypisz gest do niezmiennej geometrii kontrolki

Gest przeciągania jest przypisany do nieruchomego kontenera toru o szerokości 336 punktów. Uchwyt porusza się wewnątrz tego kontenera. Apple definiuje location jako bieżące położenie zdarzenia, a DragGesture pozwala określić układ współrzędnych używany dla położeń. Użycie .local w nieruchomym kontenerze sprawia, że raportowana współrzędna x odpowiada obliczeniom używanym przy rysowaniu. Dokumentacja DragGesture firmy Apple, dokumentacja location firmy Apple

Prostokątny kształt zawartości udostępnia obszar toru do testowania trafień, także w przestrzeniach między narysowanymi elementami. Zerowa minimalna odległość pozwala rozpoznać gest bez wymaganego początkowego progu przemieszczenia. W tym projekcie naciśnięcie dowolnego miejsca toru od razu podgląda najbliższy znacznik. Takie przeskakiwanie do wskazanego położenia jest zamierzone. Dokumentacja contentShape(_:eoFill:) firmy Apple, dokumentacja DragGesture firmy Apple

W przypadku interakcji ograniczonej do uchwytu, która zachowuje początkowe przesunięcie chwytu, zastosuj inne mapowanie: raz zapisz początkowe położenie środka uchwytu, a następnie dodaj całkowite poziome przesunięcie gestu przed zaokrągleniem. Nie dodawaj wielokrotnie całkowitego przesunięcia do już zaktualizowanego położenia środka. SwiftUI dokumentuje, że przesunięcie oznacza przemieszczenie od początku gestu, a nie ruch od poprzedniego wywołania zwrotnego. Dokumentacja translation firmy Apple

Nie przypisuj gestu do otaczającego stosu etykiet i przycisków. Przypisanie gestu wpływa na relacje rozpoznawania z dołączonym widokiem i jego widokami podrzędnymi; rozszerzenie tego zakresu może powodować konflikty z innymi kontrolkami. Jeśli osadzisz ten przykład w widoku przewijanym, przetestuj tam rozpoznawanie gestów. Gest z zerową odległością jest celowo natychmiastową interakcją, dlatego samodzielny panel stanowi najlepszy punkt wyjścia. Dokumentacja gesture(_:including:) firmy Apple

Zdecyduj, kiedy podgląd staje się zatwierdzonym stanem

Podczas przeciągania zmieniają się tylko uchwyt kontrolki i etykieta wybranej wartości. Większe koło i jego etykieta zastosowanej wartości korzystają z zatwierdzonego indeksu widoku nadrzędnego. Po zwolnieniu przycisku procedura obsługi oblicza końcowy indeks na podstawie końcowego położenia, zamiast polegać na najnowszym wywołaniu zwrotnym podglądu.

Jeśli gest stanie się nieaktywny bez zatwierdzenia wyboru przy zwolnieniu, tymczasowy podgląd zostanie zresetowany, a poprzedni zatwierdzony wybór pozostanie bez zmian. Wynika to z przechowywania stanu podglądu w @GestureState i zapisu trwałej wartości w procedurze obsługi zakończenia gestu. Dokumentacja GestureState firmy Apple

Aby zastosować zmianę na żywo, zapisuj zaokrąglony indeks w powiązaniu podczas zmian gestu. Zmienia to zasady interakcji: wybory pośrednie stają się zatwierdzonymi aktualizacjami modelu. Zdecyduj, czy takie zachowanie jest pożądane, zanim połączysz selektor z bardziej rozbudowanym renderowaniem. W obu projektach rysuj uchwyt na podstawie tego samego skwantowanego indeksu, który służy do wyznaczenia wybranej wartości.

Przyciski Smaller i Larger od razu stosują zmianę o jeden krok i zatrzymują się na krańcowych wartościach. Tor udostępnia także akcję ułatwień dostępu do zmiany wartości, której procedury zwiększania i zmniejszania korzystają z tej samej funkcji krokowej. Apple opisuje ten modyfikator jako mechanizm umożliwiający technologiom wspomagającym, takim jak VoiceOver, wywoływanie akcji zmiany wartości. Dokumentacja accessibilityAdjustableAction(_:) firmy Apple

Kiedy interakcją zarządza widok AppKit

Widok czysto oparty na SwiftUI nie wymaga mostu do AppKit w tym zadaniu. Jeśli tor znajduje się już w Twoim własnym NSView, obsługuj rozpoznawanie gestu bezpośrednio w tym widoku. addGestureRecognizer(_:) firmy Apple dołącza rozpoznawacz do widoku, ogranicza reprezentowany przez niego gest do tego widoku i jego widoków podrzędnych oraz ustanawia silne odwołanie do rozpoznawacza. Dokumentacja addGestureRecognizer(_:) firmy Apple

W implementacji AppKit używającej NSPanGestureRecognizer mierz przesunięcie w układzie współrzędnych nieruchomego widoku toru. Zapisz początkowe położenie uchwytu, dodaj raportowane całkowite przesunięcie, a następnie przekaż otrzymaną współrzędną x do tego samego obliczenia zatrzaskiwania. Apple wyraźnie dokumentuje, że translation(in:) zwraca całkowitą odległość od początku gestu, z uwzględnieniem transformacji wybranego widoku. Dokumentacja translation(in:) firmy Apple

Używaj jednej implementacji gestu dla każdej powierzchni interakcji. Jeśli tor AppKit jest osadzony w SwiftUI, pozwól mu obsługiwać przeciąganie i przekazywać zmiany wyboru przez połączenie z modelem aplikacji. Dzięki temu droga od współrzędnych zdarzenia przez podgląd do zatwierdzonego wyboru pozostaje jasna.

Sprawdź końce zakresu, progi i przerwane przeciągnięcia

Sprawdź interakcję w aplikacji na Maca, wykonując krótką, konkretną sekwencję:

  1. Zacznij od środkowego znacznika. Przeciągnij przez każdy przedział i sprawdź, czy etykieta wyboru pokazuje wyłącznie pięć dozwolonych rozmiarów.
  2. Przesuń wskaźnik poza oba poziome końce. Uchwyt powinien pozostać przy pierwszym lub ostatnim znaczniku.
  3. Zwolnij przycisk między znacznikami. Koło powinno przyjąć najbliższy rozmiar, zgodny z końcowym położeniem uchwytu.
  4. Kliknij niezajęty fragment toru. Sprawdź, czy przeskakiwanie do wskazanego położenia pasuje do zadania.
  5. Przerwij aktywną interakcję i sprawdź, czy tymczasowy podgląd nie pozostaje na ekranie.
  6. Użyj obu przycisków krokowych i funkcji zmiany wartości VoiceOver. Sprawdź, czy zatrzymują się na tych samych krańcowych wartościach.

Dla nierównomiernych opcji, takich jak 20, 30, 60 i 100, ten projekt oparty na indeksach nadal rozmieszcza opcje w równych odstępach. Jest to zamierzony układ porządkowy. Jeśli odstępy mają odzwierciedlać różnice liczbowe, mapuj każdą wartość liczbową na własne położenie na torze i wybieraj najbliższe położenie; zaokrąglanie równomiernego indeksu określałoby inny rodzaj interakcji.

Niezawodny suwak zatrzaskowy korzysta z jednego układu współrzędnych i jednego mapowania zarówno dla danych wejściowych, jak i rysowania. Gdy te elementy są zgodne, całkowity indeks zapewnia dyskretność każdej wybranej wartości, a oddzielne stany podglądu i zatwierdzenia jasno określają zachowanie po zwolnieniu przycisku.

Źródła