Damit ein benutzerdefinierter SwiftUI-Ziehregler auf diskrete Werte einrastet, wandelst du die lokale Position des Zeigers in einen begrenzten Anteil um, rundest ihn auf einen ganzzahligen Index und zeichnest den Schieber anhand dieses Index. Dieses macOS-Beispiel wählt eine von fünf Kreisgrößen aus und übernimmt die Auswahl beim Loslassen.

Interaktion festlegen, bevor du die Geste implementierst

Die Aufgabe ist einfach: Füge einer Mac-App einen benutzerdefinierten horizontalen Größenregler hinzu. Verfügbar sind die Größen 20, 40, 60, 80 und 100 Punkte. Beim Ziehen wird eine eingerastete Auswahl in der Vorschau angezeigt; beim Loslassen wird diese Größe auf einen beispielhaften Kreis angewendet. Alles bleibt im Arbeitsspeicher.

Entscheide zuerst, ob eine benutzerdefinierte Darstellung nötig ist. Apples standardmäßiger Slider unterstützt bereits diskrete Schritte über seinen Parameter step und aktualisiert den gebundenen Wert, während sich der Schieber bewegt. Für einen herkömmlichen Größenregler lassen sich diese Auswahlmöglichkeiten direkt mit einem Bereich von 20...100 und einer Schrittweite von 20 abbilden. Verwende dieses unterstützte Steuerelement, wenn Darstellung und Interaktion zur Aufgabe passen. Apples Dokumentation zu Slider

Die folgende benutzerdefinierte Implementierung ist nützlich, wenn du Spur, Markierungen, Schieber und Verhalten beim Loslassen selbst steuern musst. Sie verwendet bewusst einen ganzzahligen Auswahlindex. Die angezeigte Größe stammt aus einem Array, sodass das Modell nicht versehentlich eine Zwischengröße wie 57.3 enthalten kann.

Drei Entscheidungen bestimmen dieses Beispiel:

  • Die Markierungen sind gleichmäßig verteilt, größere Größen liegen weiter rechts.
  • Sowohl der Schieber als auch die Wertbeschriftung des Steuerelements rasten während des Ziehens ein.
  • Die ausgewählte Größe im übergeordneten Element ändert sich beim Loslassen; während der Vorschau behält der Kreis seine zuvor übernommene Größe.

Zeigerposition in einen diskreten Index umwandeln

Eingabegeometrie und Zeichengeometrie müssen übereinstimmen. Hier ist das Steuerelement 336 Punkte breit und der Schieber hat einen Radius von 12 Punkten. Sein Mittelpunkt bewegt sich daher von 12 bis 324; die nutzbare Spurlänge beträgt also 312 Punkte. Würde für die Eingabeberechnung die gesamte Breite verwendet, würden Zeigerposition und Endmarkierungen nicht übereinstimmen.

Die Abbildung umfasst vier Schritte: den linken Randabstand abziehen, durch die nutzbare Spurlänge teilen, auf den Bereich 0...1 begrenzen und mit der Anzahl der Intervalle multiplizieren, bevor gerundet wird. Bei fünf Auswahlmöglichkeiten gibt es vier Intervalle. Die umgekehrte Abbildung positioniert den ausgewählten Schieber auf der passenden Markierung.

Die folgende Hilfsfunktion ist Teil der vollständigen App, die im nächsten Abschnitt zusammengestellt wird. Ihre Rundungsregel wählt bei einem exakten Mittelpunkt den höheren Index, da normalisierte Indizes nicht negativ sind. Swift beschreibt .toNearestOrAwayFromZero als Rundung auf den nächsten Wert, wobei Gleichstände von null weg gerundet werden. Apples Dokumentation zu 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)
    }
}

Diese beispielhaften Eingaben zeigen das Randverhalten für die feste Geometrie. Durch die Begrenzung bleibt ein Ziehen über einen der Endpunkte hinaus auf einer gültigen Auswahl; die Interaktion wird nicht verworfen.

x-Koordinate des Zeigers Ausgewählter Index Größe in Punkten
-10 0 20
12 0 20
51 1 40
90 1 40
168 2 60
324 4 100
350 4 100

Bei 51 befindet sich der Zeiger genau in der Mitte zwischen den ersten beiden Markierungen; daher wählt die Regel den Index 1. Ich habe diese deterministischen Berechnungen mit Python überprüft. In der Entwicklungsumgebung war kein Swift-Compiler verfügbar. Der folgende SwiftUI-App-Code wurde daher überprüft, aber hier nicht kompiliert oder ausgeführt.

macOS-Steuerelement und übergeordnete Ansicht erstellen

Erstelle in Xcode ein macOS-App-Projekt mit SwiftUI und einem Deployment-Ziel ab macOS 13. Ersetze die generierte App-Deklaration und Inhaltsansicht durch den folgenden Code und füge die vorherige Hilfsfunktion in dieselbe Swift-Datei ein. Die Hilfsfunktion und die nächsten beiden Codeblöcke bilden zusammen ein vollständiges Programm; verwende nur eine @main-App-Deklaration.

Das übergeordnete Element verwaltet den übernommenen Index in @State. Das Steuerelement erhält ein @Binding, das es mit dieser maßgeblichen Datenquelle verbindet, ohne eine zweite dauerhafte Auswahl anzulegen. Das entspricht Apples dokumentiertem Besitzmuster für Bindings. Apples Dokumentation zu 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)
    }
}

Das Steuerelement verwendet @GestureState für die vorübergehende Vorschau. Apple dokumentiert, dass der Gestenzustand auf seinen Anfangswert zurückgesetzt wird, sobald die Geste inaktiv wird. Hier sorgt die Rückkehr zu nil dafür, dass das Steuerelement wieder den übernommenen Binding-Wert anzeigt. Daher muss eine vorübergehende Auswahl nicht manuell in einer separaten dauerhaften Variablen bereinigt werden. Apples Dokumentation zu 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
                    )
            }
        }
    }
}

Dieses Beispiel setzt ausdrücklich voraus, dass das Größen-Array nicht leer ist, der übernommene Index gültig ist und das Array während einer Interaktion unverändert bleibt. Das bereitgestellte übergeordnete Element erfüllt alle drei Voraussetzungen. Der defensive Rückgabewert der Geometrie-Hilfsfunktion macht ein leeres Array für das Steuerelement nicht gültig, da die Beschriftung weiterhin auf dieses Array zugreift. Wenn die Auswahlmöglichkeiten aus einem veränderlichen Modell stammen, validiere die Sammlung und die Auswahl vor dem Rendern und lege fest, was bei Änderungen während eines Ziehvorgangs geschehen soll.

Geste an die stabile Geometrie des Steuerelements binden

Die Geste gehört zum stationären Spurcontainer mit einer Breite von 336 Punkten. Der Schieber bewegt sich innerhalb dieses Containers. Apple definiert location als aktuelle Ereignisposition. Für DragGesture lässt sich außerdem der Koordinatenraum angeben, in dem Positionen ermittelt werden. Wenn du .local für den stabilen Container verwendest, entspricht die gemeldete x-Koordinate den Zeichenberechnungen. Apples Dokumentation zu DragGesture, Apples Dokumentation zu location

Die rechteckige Inhaltsform macht den Spurberich des Steuerelements für die Treffererkennung verfügbar, auch zwischen den gezeichneten Formen. Der minimale Abstand von null ermöglicht die Erkennung, ohne dass zunächst eine bestimmte Strecke zurückgelegt werden muss. In diesem Entwurf wird beim Drücken an einer beliebigen Stelle der Spur sofort die nächstgelegene Markierung in der Vorschau ausgewählt. Dieses Springen zur Position ist beabsichtigt. Apples Dokumentation zu contentShape(_:eoFill:), Apples Dokumentation zu DragGesture

Für eine Interaktion, die nur am Schieber beginnt und den anfänglichen Greifversatz beibehält, brauchst du eine andere Zuordnung: Erfasse den anfänglichen Mittelpunkt des Schiebers einmal und addiere dann die gesamte horizontale Verschiebung der Geste, bevor du einrasten lässt. Addiere die Gesamtverschiebung nicht wiederholt zu einem bereits aktualisierten Mittelpunkt. SwiftUI dokumentiert die Verschiebung als Abstand vom Beginn der Geste, nicht als Bewegung seit dem vorherigen Callback. Apples Dokumentation zu translation

Binde die Geste nicht an den umgebenden Stapel aus Beschriftungen und Schaltflächen. Das Anbinden einer Geste beeinflusst die Erkennungsbeziehungen zur Ansicht und ihren Unteransichten; ein größerer Bereich kann zu Konflikten mit anderen Steuerelementen führen. Wenn du dieses Beispiel in eine scrollbare Ansicht einbettest, überprüfe die Gestenerkennung dort ausdrücklich. Eine Ziehgeste mit Abstand null ist eine bewusst sofortige Interaktion. Daher ist ein eigenständiges Bedienfeld der klarste Ausgangspunkt. Apples Dokumentation zu gesture(_:including:)

Festlegen, wann die Vorschau zum übernommenen Zustand wird

Während des Ziehens ändern sich nur der Schieber und die Beschriftung mit dem ausgewählten Wert. Der größere Kreis und seine Beschriftung für den angewendeten Wert folgen dem übernommenen Index des übergeordneten Elements. Beim Loslassen berechnet der Handler den endgültigen Index anhand der letzten Position, statt sich auf den jüngsten Vorschau-Callback zu verlassen.

Wird die Geste inaktiv, ohne dass beim Loslassen eine Übernahme erfolgt, wird die vorübergehende Vorschau zurückgesetzt und die vorherige übernommene Auswahl bleibt bestehen. Dieses Verhalten ergibt sich daraus, dass der Vorschauzustand in @GestureState liegt und die dauerhafte Änderung im End-Handler erfolgt. Apples Dokumentation zu GestureState

Für eine sofortige Anwendung schreibst du stattdessen bei Änderungen während des Ziehens den eingerasteten Index in das Binding. Dadurch ändert sich die Interaktion: Zwischenwerte werden zu übernommenen Modelländerungen. Entscheide vor der Verbindung des Reglers mit aufwendigeren Rendering-Arbeiten, ob das erwünscht ist. Zeichne in beiden Entwürfen den Schieber anhand desselben quantisierten Index, der auch für den ausgewählten Wert verwendet wird.

Die Schaltflächen Smaller und Larger wenden sofort eine Änderung um eine Stufe an und halten an den Endpunkten an. Außerdem stellt die Spur eine anpassbare Bedienungshilfen-Aktion bereit, deren Handler zum Erhöhen und Verringern dieselbe Schritt-Funktion verwenden. Apple dokumentiert diesen Modifikator als Möglichkeit für Hilfstechnologien wie VoiceOver, anpassbare Aktionen auszuführen. Apples Dokumentation zu accessibilityAdjustableAction(_:)

Wenn eine AppKit-Ansicht die Interaktion verwaltet

Eine reine SwiftUI-Ansicht benötigt für diese Aufgabe keine AppKit-Brücke. Wenn sich die Spur bereits in einer eigenen NSView befindet, verwalte die Erkennung stattdessen in dieser Ansicht. Apples addGestureRecognizer(_:) fügt einer Ansicht einen Erkenner hinzu, beschränkt die von ihm erkannte Geste auf diese Ansicht und ihre Unteransichten und hält eine starke Referenz auf den Erkenner. Apples Dokumentation zu addGestureRecognizer(_:)

Miss bei einer AppKit-Implementierung mit NSPanGestureRecognizer die Verschiebung im Koordinatenraum der stationären Spuransicht. Erfasse die Ausgangsposition des Schiebers, addiere die gemeldete Gesamtverschiebung und übergib dann die resultierende x-Koordinate an dieselbe Einrastberechnung. Apple dokumentiert ausdrücklich, dass translation(in:) die Gesamtstrecke seit Beginn der Geste zurückgibt, wobei die Transformation der ausgewählten Ansicht berücksichtigt wird. Apples Dokumentation zu translation(in:)

Verwende für jede Interaktionsfläche nur eine Gestenimplementierung. Wenn eine AppKit-Spur in SwiftUI eingebettet ist, sollte die Spur ihre Zieherkennung selbst verwalten und Auswahländerungen über die Modellverbindung der App melden. So bleibt der Weg von Ereigniskoordinaten über die Vorschau bis zur übernommenen Auswahl klar.

Endpunkte, Schwellenwerte und unterbrochene Ziehvorgänge prüfen

Überprüfe die Interaktion in der Mac-App mit einer kurzen, konkreten Abfolge:

  1. Beginne an der mittleren Markierung. Ziehe über jedes Intervall und vergewissere dich, dass die ausgewählte Beschriftung nur die fünf zulässigen Größen anzeigt.
  2. Bewege den Zeiger über beide horizontalen Endpunkte hinaus. Der Schieber sollte auf der ersten oder letzten Markierung bleiben.
  3. Lass zwischen Markierungen los. Der Kreis sollte die nächstgelegene Größe übernehmen, passend zur letzten Position des Schiebers.
  4. Klicke auf einen ungenutzten Bereich der Spur. Prüfe, ob das Springen zur Position zur Aufgabe passt.
  5. Unterbrich eine aktive Interaktion und überprüfe, dass keine Vorschau hängen bleibt.
  6. Verwende beide Schaltflächen zum schrittweisen Ändern und die VoiceOver-Anpassungen. Vergewissere dich, dass sie an denselben Endpunkten stoppen.

Bei ungleichmäßigen Auswahlmöglichkeiten wie 20, 30, 60 und 100 verteilt dieses indexbasierte Design die Auswahlmöglichkeiten weiterhin gleichmäßig. Das ist eine bewusste ordinale Anordnung. Soll der Abstand stattdessen die numerischen Distanzen widerspiegeln, ordne jedem Zahlenwert eine eigene Position auf der Spur zu und wähle die nächstgelegene Position. Einen gleichmäßigen Index zu runden würde eine andere Interaktion abbilden.

Ein zuverlässiger Einrastregler verwendet für Eingabe und Zeichnung dasselbe Koordinatensystem und dieselbe Zuordnung. Stimmen beide überein, hält ein ganzzahliger Index jeden ausgewählten Wert diskret. Getrennter Vorschau- und übernommener Zustand macht das Verhalten beim Loslassen eindeutig.

Quellen