Per fare in modo che un controllo SwiftUI personalizzato di trascinamento si agganci a valori discreti, converti la posizione locale del puntatore in una frazione limitata, arrotondala a un indice intero e disegna il cursore in base a quell’indice. Questo esempio per macOS seleziona una di cinque dimensioni del cerchio e applica la selezione al rilascio.

Scegliere l’interazione prima di scrivere il gesto

Il compito del lettore è semplice: aggiungere a un’app per Mac un selettore personalizzato orizzontale delle dimensioni. Le dimensioni disponibili sono 20, 40, 60, 80 e 100 punti. Il trascinamento mostra in anteprima una selezione agganciata a uno scatto; al rilascio, quella dimensione viene applicata a un cerchio simulato. Tutto resta in memoria.

Per prima cosa, decidi se è necessario disegnare un controllo personalizzato. Il controllo standard Slider di Apple supporta già incrementi discreti tramite il parametro step e aggiorna il valore associato mentre il cursore si sposta. Per un selettore convenzionale delle dimensioni, un intervallo di 20...100 con un passo di 20 rappresenta direttamente queste scelte. Usa questo controllo supportato quando il suo aspetto e la sua interazione soddisfano il compito. Documentazione Apple di Slider

L’implementazione personalizzata qui sotto è utile quando devi controllare la traccia, i contrassegni, il cursore e il comportamento al rilascio. Usa deliberatamente un indice intero per la selezione. La dimensione visualizzata proviene da un array, quindi il modello non può contenere per errore una dimensione intermedia come 57.3.

Tre decisioni definiscono questo esempio:

  • Le posizioni dei contrassegni sono equidistanti, con le dimensioni maggiori a destra.
  • Sia il cursore sia l’etichetta del valore del controllo si agganciano a uno scatto durante il trascinamento.
  • La dimensione selezionata dal genitore cambia al rilascio; durante l’anteprima, il cerchio mantiene la dimensione applicata in precedenza.

Convertire la posizione del puntatore in un indice discreto

Mantieni coerenti la geometria dell’input e quella del disegno. In questo caso, il controllo è largo 336 punti e il cursore ha un raggio di 12 punti. Il suo centro si sposta quindi da 12 a 324, per una lunghezza utile della traccia di 312 punti. Usare tutta la larghezza nel calcolo dell’input farebbe divergere la posizione del puntatore da quella dei contrassegni agli estremi.

La mappatura comprende quattro operazioni: sottrarre l’inset a sinistra, dividere per la lunghezza utile della traccia, limitare il risultato all’intervallo 0...1 e moltiplicarlo per il numero di intervalli prima di arrotondare. Cinque scelte hanno quattro intervalli. La mappatura inversa posiziona il cursore selezionato sul contrassegno corrispondente.

La funzione di supporto seguente fa parte dell’app completa assemblata nella prossima sezione. La sua regola di arrotondamento sceglie l’indice superiore in corrispondenza del punto medio esatto, perché gli indici normalizzati sono non negativi. Swift documenta .toNearestOrAwayFromZero come arrotondamento al più vicino, con i casi di parità arrotondati lontano da zero. Documentazione Apple di rounded(_:)

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

Questi input illustrativi mostrano il comportamento ai limiti per la geometria fissa. Il limite fa sì che un trascinamento oltre uno dei due estremi resti su una scelta valida; non annulla l’interazione.

Coordinata x del puntatore Indice selezionato Dimensione in punti
-10 0 20
12 0 20
51 1 40
90 1 40
168 2 60
324 4 100
350 4 100

In corrispondenza di 51, il puntatore si trova esattamente a metà tra i primi due contrassegni, quindi la regola seleziona l’indice 1. Ho verificato questi calcoli deterministici con Python. Nell’ambiente di ricerca non era disponibile un compilatore Swift, quindi l’app SwiftUI qui sotto è stata esaminata ma non è stata compilata né eseguita.

Creare il controllo macOS e la vista che lo contiene

In Xcode, crea un progetto per un’app macOS usando SwiftUI e impostando come versione minima di distribuzione macOS 13 o successiva. Sostituisci la dichiarazione dell’app generata e la vista del contenuto con il codice qui sotto e includi la funzione di supporto precedente nello stesso file Swift. La funzione di supporto e i due blocchi seguenti formano insieme un programma completo; mantieni una sola dichiarazione @main dell’app.

Il genitore possiede l’indice applicato in @State. Il controllo riceve un @Binding, che lo collega a questa fonte autorevole senza creare una seconda selezione persistente. Questo segue il modello di proprietà documentato da Apple per i binding. Documentazione Apple di Binding

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

Il controllo usa @GestureState per l’anteprima temporanea. Apple documenta che lo stato del gesto torna al valore iniziale quando il gesto diventa inattivo. In questo caso, tornare a nil fa sì che il controllo mostri di nuovo il binding applicato, quindi non occorre ripulire manualmente una variabile persistente separata per eliminare la selezione temporanea. Documentazione Apple di GestureState

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

Questo esempio ha prerequisiti espliciti: l’array delle dimensioni non è vuoto, l’indice applicato è valido e l’array rimane invariato durante un’interazione. Il genitore fornito soddisfa tutti e tre i prerequisiti. Il valore di ripiego difensivo della funzione geometrica non rende valido un array vuoto per il controllo, perché l’etichetta continua a indicizzare quell’array. Se le scelte provengono da un modello soggetto a modifiche, convalida la raccolta e la selezione prima del rendering e definisci cosa deve accadere se le scelte cambiano durante un trascinamento.

Mantenere il gesto collegato alla geometria stabile del controllo

Il trascinamento è collegato al contenitore fisso della traccia, largo 336 punti. Il cursore si sposta all’interno del contenitore. Apple definisce location come la posizione corrente dell’evento e consente a DragGesture di specificare lo spazio di coordinate usato per le posizioni. Usare .local sul contenitore stabile fa sì che la coordinata x riportata corrisponda ai calcoli del disegno. Documentazione Apple di DragGesture, Documentazione Apple di location

La forma rettangolare del contenuto rende l’area della traccia disponibile per il rilevamento dei tocchi, compresi gli spazi tra le forme disegnate. La distanza minima pari a zero permette di riconoscere il gesto senza richiedere una soglia iniziale di spostamento. In questo progetto, premere in un punto qualsiasi della traccia mostra subito in anteprima il contrassegno più vicino. Questo salto alla posizione è intenzionale. Documentazione Apple di contentShape(_:eoFill:), Documentazione Apple di DragGesture

Per un’interazione limitata al cursore che mantenga lo scarto iniziale del punto di presa, usa una mappatura diversa: acquisisci una sola volta il centro iniziale del cursore, poi aggiungi la traslazione orizzontale totale del gesto prima di agganciare il valore. Non aggiungere ripetutamente la traslazione totale a un centro già aggiornato. SwiftUI documenta la traslazione come lo spostamento dall’inizio del gesto, non come il movimento dall’ultima callback. Documentazione Apple di translation

Collega il gesto direttamente alla traccia, non allo stack circostante di etichette e pulsanti. L’associazione del gesto influisce sulle relazioni di riconoscimento con la vista a cui è collegato e con le relative viste secondarie; estendere l’ambito può causare conflitti con altri controlli. Se inserisci questo esempio in una vista a scorrimento, verifica esplicitamente il riconoscimento in quel contesto. Un trascinamento a distanza zero è un’interazione immediata intenzionale, quindi un pannello autonomo è il punto di partenza più chiaro. Documentazione Apple di gesture(_:including:)

Decidere quando l’anteprima diventa stato applicato

Durante il trascinamento cambiano solo il cursore del controllo e l’etichetta del valore selezionato. Il cerchio più grande e la relativa etichetta del valore applicato seguono l’indice applicato dal genitore. Al rilascio, il gestore calcola l’indice finale dalla posizione finale, invece di affidarsi alla callback di anteprima più recente.

Se il gesto diventa inattivo senza applicare la selezione al rilascio, l’anteprima temporanea si azzera e la selezione applicata in precedenza rimane invariata. Questo comportamento deriva dal fatto che lo stato dell’anteprima è in @GestureState e la scrittura persistente avviene nel gestore di fine gesto. Documentazione Apple di GestureState

Per applicare le modifiche in tempo reale, scrivi l’indice agganciato nel binding durante le modifiche del trascinamento. Questo cambia il contratto dell’interazione: le scelte intermedie diventano aggiornamenti applicati al modello. Decidi se è opportuno prima di collegare il selettore ad attività di rendering più impegnative. In entrambi i progetti, disegna il cursore usando lo stesso indice quantizzato del valore selezionato.

I pulsanti Più piccolo e Più grande applicano subito una modifica di un passo e si fermano agli estremi. La traccia espone anche un’azione di accessibilità regolabile, i cui gestori di incremento e decremento usano la stessa funzione di avanzamento. Apple documenta questo modificatore come il meccanismo che consente alle tecnologie assistive, come VoiceOver, di richiamare azioni regolabili. Documentazione Apple di accessibilityAdjustableAction(_:)

Quando una vista AppKit gestisce l’interazione

Una vista SwiftUI pura non ha bisogno di un ponte AppKit per questo compito. Se la traccia si trova già in una tua NSView, gestisci il riconoscimento direttamente in quella vista. addGestureRecognizer(_:) di Apple collega un riconoscitore a una vista, ne limita il gesto rappresentato alla vista e alle relative viste secondarie e mantiene un riferimento forte al riconoscitore. Documentazione Apple di addGestureRecognizer(_:)

Per un’implementazione AppKit che usa NSPanGestureRecognizer, misura la traslazione nel sistema di coordinate della vista fissa della traccia. Acquisisci la posizione iniziale del cursore, aggiungi la traslazione totale riportata, poi passa la coordinata x risultante allo stesso calcolo di aggancio. Apple documenta esplicitamente che translation(in:) restituisce la distanza totale dall’inizio del gesto, con la trasformazione della vista scelta applicata. Documentazione Apple di translation(in:)

Usa una sola implementazione del gesto per ciascuna superficie di interazione. Se una traccia AppKit è incorporata in SwiftUI, lascia che sia quella traccia a gestire il pan e a comunicare le modifiche della selezione tramite il collegamento al modello dell’app. In questo modo si mantiene chiaro il percorso dalle coordinate dell’evento all’anteprima e poi alla selezione applicata.

Verificare estremi, soglie e trascinamenti interrotti

Verifica l’interazione nell’app per Mac con una sequenza breve e concreta:

  1. Inizia dal contrassegno centrale. Trascina attraverso ogni intervallo e verifica che l’etichetta della selezione mostri solo le cinque dimensioni consentite.
  2. Spostati oltre entrambi gli estremi orizzontali. Il cursore dovrebbe rimanere sul primo o sull’ultimo contrassegno.
  3. Rilascia tra due contrassegni. Il cerchio dovrebbe adottare la dimensione più vicina, in corrispondenza della posizione finale del cursore.
  4. Fai clic su una parte libera della traccia. Verifica che il salto alla posizione sia adatto al compito.
  5. Interrompi un’interazione attiva e verifica che non rimanga un’anteprima temporanea bloccata.
  6. Usa entrambi i pulsanti di avanzamento e le regolazioni di VoiceOver. Verifica che si fermino agli stessi estremi.

Per scelte non uniformi come 20, 30, 60 e 100, questo progetto basato su indici mantiene comunque una spaziatura uniforme tra le scelte. È una disposizione ordinale intenzionale. Se la spaziatura deve riflettere invece la distanza numerica, mappa ogni valore numerico sulla propria posizione della traccia e seleziona la posizione più vicina; arrotondare un indice uniforme rappresenterebbe un’interazione diversa.

Un controllo con aggancio affidabile usa un unico sistema di coordinate e un’unica mappatura sia per l’input sia per il disegno. Quando questi elementi sono allineati, un indice intero mantiene discreto ogni valore selezionato, mentre la separazione tra stato di anteprima e stato applicato rende esplicito il comportamento al rilascio.

Fonti