To make a custom SwiftUI drag control snap to discrete values, convert the pointer’s local position into a bounded fraction, round it to an integer index, and draw the thumb from that index. This macOS example selects one of five circle sizes and commits the selection on release.
Choose the interaction before writing the gesture
The reader task is straightforward: add a custom horizontal size selector to a Mac app. Its available sizes are 20, 40, 60, 80, and 100 points. Dragging previews a snapped selection; releasing applies that size to a synthetic circle. Everything stays in memory.
First decide whether custom drawing is necessary. Apple’s standard Slider already supports discrete increments through its step parameter and updates its bound value as the thumb moves. For a conventional size selector, a range of 20...100 with a step of 20 expresses these choices directly. Use that supported control when its appearance and interaction meet the task. Apple’s Slider documentation
The custom implementation below is useful when you need to control the track, tick marks, thumb, and release behavior. It deliberately uses an integer selection index. The displayed size comes from an array, so the model cannot accidentally hold an intermediate size such as 57.3.
Three decisions define this example:
- Tick positions are equally spaced, with larger sizes toward the right.
- Both the thumb and the control’s value label snap during dragging.
- The parent’s selected size changes on release; the circle retains its previously committed size during the preview.
Convert pointer position into a discrete index
Keep input geometry and drawing geometry consistent. Here, the control is 336 points wide and the thumb has a 12-point radius. Its center therefore travels from 12 to 324, giving a usable track length of 312 points. Using the full width for the input calculation would make the pointer and endpoint tick positions disagree.
The mapping has four operations: subtract the left inset, divide by the usable track length, clamp to the range 0...1, and multiply by the number of intervals before rounding. Five choices have four intervals. The inverse mapping places the selected thumb at the matching tick.
The following helper is part of the complete app assembled in the next section. Its rounding policy chooses the higher index at an exact midpoint because normalized indices are nonnegative. Swift documents .toNearestOrAwayFromZero as nearest rounding with ties away from zero. Apple’s rounded(_:) documentation
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)
}
}
These illustrative inputs show the boundary behavior for the fixed geometry. Clamping keeps a drag beyond either endpoint on a valid choice; it does not discard the interaction.
| Pointer x-coordinate | Selected index | Size in points |
|---|---|---|
-10 |
0 |
20 |
12 |
0 |
20 |
51 |
1 |
40 |
90 |
1 |
40 |
168 |
2 |
60 |
324 |
4 |
100 |
350 |
4 |
100 |
At 51, the pointer is exactly halfway between the first two ticks, so the rule selects index 1. I checked these deterministic calculations with Python. A Swift compiler was unavailable in the research environment, so the SwiftUI app below has been inspected but has not been compiled or run here.
Build the macOS control and its owning view
Create a macOS App project in Xcode using SwiftUI and a deployment target of macOS 13 or later. Replace the generated app declaration and content view with the code below, and include the preceding helper in the same Swift file. The helper and the next two blocks together form one complete program; keep only one @main app declaration.
The parent owns the committed index in @State. The control receives a @Binding, which connects it to that source of truth without creating a second persistent selection. This follows Apple’s documented ownership pattern for bindings. Apple’s Binding documentation
@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)
}
}
The control uses @GestureState for the temporary preview. Apple documents that gesture state resets to its initial value when the gesture becomes inactive. Here, returning to nil makes the control display the committed binding again, so a temporary selection does not need manual cleanup in a separate persistent variable. Apple’s GestureState documentation
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
)
}
}
}
}
This sample has explicit prerequisites: the sizes array is nonempty, the committed index is valid, and the array remains fixed during an interaction. The supplied parent satisfies all three. The geometry helper’s defensive return value does not make an empty array valid for the control, because the label still indexes that array. If choices come from a changing model, validate the collection and selection before rendering, and define what happens when choices change during a drag.
Keep the gesture attached to stable control geometry
The drag belongs to the stationary 336-point track container. Its thumb moves inside that container. Apple defines location as the current event location and lets DragGesture specify the coordinate space used for locations. Using .local on the stable container makes the reported x-coordinate match the drawing calculations. Apple’s DragGesture documentation, Apple’s location documentation
The rectangular content shape makes the control’s track area available for hit testing, including spaces between its drawn shapes. The zero minimum distance permits recognition without requiring an initial travel threshold. In this design, pressing elsewhere on the track previews the nearest tick immediately. That jump-to-position behavior is intentional. Apple’s contentShape(_:eoFill:) documentation, Apple’s DragGesture documentation
For a thumb-only interaction that preserves the initial grab offset, use a different mapping: capture the thumb’s starting center once, then add the gesture’s total horizontal translation before snapping. Do not repeatedly add total translation to an already updated center. SwiftUI documents translation as displacement from the start of the gesture, rather than movement since the preceding callback. Apple’s translation documentation
Keep the gesture off the surrounding stack of labels and buttons. Gesture attachment affects recognition relationships with the attached view and its subviews; enlarging that scope can introduce competition with other controls. If you embed this example in a scrolling view, test recognition there explicitly. A zero-distance drag is an intentional immediate interaction, so a standalone panel is the clearest starting point. Apple’s gesture(_:including:) documentation
Decide when preview becomes committed state
During dragging, only the control’s thumb and selected-value label change. The larger circle and its applied-value label follow the parent’s committed index. On release, the handler calculates the final index from the final location, rather than relying on the most recent preview callback.
If the gesture becomes inactive without that release commit, the transient preview resets and the previous committed selection remains. This behavior follows from keeping preview state in @GestureState and putting the persistent write in the end handler. Apple’s GestureState documentation
For live application instead, write the snapped index to the binding during drag changes. That changes the interaction contract: intermediate choices become committed model updates. Decide whether that is desirable before connecting the selector to more substantial rendering work. In either design, draw the thumb from the same quantized index used for the selected value.
The Smaller and Larger buttons apply one-step changes immediately and stop at the endpoints. The track also exposes an adjustable accessibility action whose increment and decrement handlers use the same stepping function. Apple documents this modifier as the mechanism for assistive technologies such as VoiceOver to invoke adjustable actions. Apple’s accessibilityAdjustableAction(_:) documentation
When an AppKit view owns the interaction
A pure SwiftUI view does not need an AppKit bridge for this task. If the track already lives in your own NSView, keep recognition in that view instead. Apple’s addGestureRecognizer(_:) attaches a recognizer to a view, scopes its represented gesture to that view and its subviews, and establishes a strong reference to the recognizer. Apple’s addGestureRecognizer(_:) documentation
For an AppKit implementation using NSPanGestureRecognizer, measure translation in the stationary track view’s coordinate system. Capture the starting thumb position, add the reported total translation, then pass the resulting x-coordinate through the same snapping calculation. Apple explicitly documents that translation(in:) returns total distance since the gesture began, with the chosen view’s transform applied. Apple’s translation(in:) documentation
Use one gesture implementation for each interaction surface. If an AppKit track is embedded in SwiftUI, let that track own its pan handling and report selection changes through the app’s model connection. This preserves a clear path from event coordinates to preview to committed selection.
Check endpoints, thresholds, and interrupted drags
Verify the interaction in the Mac app with a short, concrete sequence:
- Start at the middle tick. Drag across every interval and confirm that the selected label shows only the five permitted sizes.
- Move beyond both horizontal endpoints. The thumb should remain on the first or last tick.
- Release between ticks. The circle should adopt the nearest size, matching the final thumb position.
- Click an unoccupied part of the track. Confirm that jump-to-position behavior suits the task.
- Interrupt an active interaction and check that no temporary preview remains stuck.
- Use both step buttons and VoiceOver adjustments. Confirm that they stop at the same endpoints.
For uneven choices such as 20, 30, 60, and 100, this index-based design still spaces choices equally. That is a deliberate ordinal layout. If spacing should instead reflect numeric distance, map each numeric value to its own track position and select the nearest position; rounding a uniform index would express a different interaction.
A reliable snapping control uses one coordinate system and one mapping for both input and drawing. With those aligned, an integer index keeps each selected value discrete, while separate preview and committed state make release behavior explicit.
Sources
- Slider — Apple
- rounded(_:) — Apple
- Binding — Apple
- GestureState — Apple
- DragGesture — Apple
- location — Apple
- contentShape(_:eoFill:) — Apple
- translation — Apple
- gesture(_:including:) — Apple
- accessibilityAdjustableAction(_:) — Apple
- addGestureRecognizer(_:) — Apple
- translation(in:) — Apple