NaturalCam/Tutorials/Circular sliders
How to build a circular slider in SwiftUI
NaturalCam's shutter speed, ISO and focal length all live on arcs that pivot on the shutter button. This is that control, built from nothing in ten steps — the maths, the hit testing, the left-handed mirror and the detents — with the finished file at the end and no dependencies beyond SwiftUI.
A circular slider is a ladder of values laid along an arc instead of a line. Every guide to building one starts with trigonometry, which is the easy part and not the part that decides whether the control is any good. What decides that is where the pivot goes — and in this one it goes somewhere that sounds wrong until you hold it.
The interactive wheel needs JavaScript. Everything it demonstrates is described and listed in full below.
Step 0The one idea
The pivot sits outside the control, in the corner the holding hand grips. That is the whole trick, and it is an ergonomic claim rather than a graphics one.
A thumb resting on the shutter button and sweeping outwards travels a circle about roughly the base of that thumb. A ladder of values laid along that circle is therefore the same reach at both ends. A straight row across the bottom of the phone is not: one end sits under your palm and the other needs a regrip.
Two numbers fall out of it, and they constrain everything after:
- ≈140 points — how far a thumb reaches before the hand has to move. Every piece of ink in the control lives inside that radius. Drift past it and you have quietly shipped a two-handed control on a one-handed camera.
- 110° — a quarter turn is what a thumb sweeps comfortably; the few degrees either side are what it can still reach when it wants the end of the ladder. Starting at −10° and finishing at 100° buys about a fifth more track for nothing.
Step 1Geometry is arithmetic, not views
The first real decision is architectural: none of the maths touches SwiftUI.
enum WheelGeometry {
static let startDegrees: Double = -10
static let sweepDegrees: Double = 110
static var endDegrees: Double { startDegrees + sweepDegrees }
}
A caseless enum of static functions over Double and
CGPoint. No view state, no @Environment, no
CoreAnimation. In NaturalCam it lives in a separate module that does not even
link SwiftUI.
Why bother: a circular control has about a dozen ways to be subtly wrong — off by a rung, mirrored on one axis too many, wrapping where it should clamp — and none of them are things you want to find by squinting at a simulator. Pure functions you can assert on. There are 26 tests on this one file in the app, and they have caught real bugs.
Step 2Rungs to angles, and back
The ladder is discrete. Values sit on rungs, spread evenly across the sweep:
static func angle(forIndex index: Int, count: Int) -> Double {
guard count > 1 else { return startDegrees }
let clamped = index.clamped(to: 0...(count - 1))
return startDegrees + Double(clamped) * sweepDegrees / Double(count - 1)
}
That guard count > 1 is not defensive noise. A one-rung ladder
has no gaps to divide by, and count - 1 is a division by zero —
which on a camera means the one lens reporting a single framing crashes the
app.
The inverse is the important half, because it is what a finger runs through:
static func index(forAngle angle: Double, count: Int) -> Int {
guard count > 1 else { return 0 }
let clamped = angle.clamped(to: startDegrees...endDegrees)
return Int(((clamped - startDegrees) / sweepDegrees * Double(count - 1)).rounded())
.clamped(to: 0...(count - 1))
}
Absolute, not accumulated. The obvious way to write a rotary control is to track the change in angle between drag events and add it to the current value, like a physical wheel with inertia. Don't. Where the thumb is is the value — because the ticks are drawn right there to aim at, and an accumulating dial drifts out of sync with its own engraving the moment you lift and re-place your thumb.
Clamp, do not wrap. That
.clamped(to: startDegrees...endDegrees) means a thumb
sliding past the end of the arc holds the last rung. Wrapping
would take you from 1/8000 round to a full second in the middle of a
shot.
Step 3Screen points, and the left-handed mirror
static func point(radius: CGFloat, angleDegrees: Double,
pivot: CGPoint, mirrored: Bool) -> CGPoint {
let radians = angleDegrees * .pi / 180
let horizontal = radius * CGFloat(cos(radians))
return CGPoint(
x: mirrored ? pivot.x + horizontal : pivot.x - horizontal,
y: pivot.y - radius * CGFloat(sin(radians)) // y grows downwards
)
}
Right-handed, 0° is nine o'clock and 90° is straight up — the arc sweeps away from the hand on the right, round to the top. Left-handed, it pivots on the other corner and sweeps the other way. Try the toggle on the demo above.
This is the bit people get wrong. The tempting move is to mirror the
whole view with .scaleEffect(x: -1) — the
scaleX(-1) reflex from CSS. Do that and your labels come out
backwards, and you spend the next hour un-mirroring each one. Flipping a
single term in one function costs nothing and keeps every glyph upright.
Step 4Reading a touch back
static func angle(at point: CGPoint, pivot: CGPoint, mirrored: Bool) -> Double {
let horizontal = mirrored ? point.x - pivot.x : pivot.x - point.x
let vertical = pivot.y - point.y
let radians = atan2(Double(vertical), Double(horizontal))
return (radians * 180 / .pi).clamped(to: startDegrees...endDegrees)
}
atan2 of the same two components, mirrored the same way, so a
touch reads back the angle it was drawn at. That symmetry is worth a test of
its own — “sweeping up means further along the ladder in both hands”
is exactly the property that silently inverts for left-handed users
otherwise.
Note there is no radius term in the answer. Once your thumb is on the band, how far out it drifts does not matter — only the angle. Which is what makes the control forgiving: you can sweep sloppily and still land on the rung you aimed at.
Step 5Drawing the band
The band is a Shape that walks the arc as a polyline: out along
the outer radius, back along the inner, close.
private let arcSteps = 72
private struct AnnulusSector: Shape {
let layout: WheelSlider.Layout
let innerRadius: CGFloat
let outerRadius: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
for step in 0...arcSteps {
let point = layout.point(radius: outerRadius, angle: sweepAngle(step))
if step == 0 { path.move(to: point) } else { path.addLine(to: point) }
}
for step in stride(from: arcSteps, through: 0, by: -1) {
path.addLine(to: layout.point(radius: innerRadius, angle: sweepAngle(step)))
}
path.closeSubpath()
return path
}
}
You could use Path.addArc and save the loop. The reason not to:
every point in the control — band edges, tick ends, label positions — goes
through the same layout.point(radius:angle:), which is where
mirroring and scaling live. One code path for both hands beats two that have
to agree with each other. At 72 segments over 110° the facets are invisible
and the cost is nil.
Step 6Ticks, and the view-identity trap
Every rung gets a tick. The obvious implementation is a ForEach
over the rungs, each drawing a little line.
Don't. Fourteen rungs is fourteen more views for SwiftUI to diff on
every frame of a drag. Draw them as one
Shape instead, batched by style:
private struct TickMarks: Shape {
let layout: WheelSlider.Layout
let rungs: [Int]
let count: Int
let inner: CGFloat
let outer: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
for rung in rungs {
let angle = WheelGeometry.angle(forIndex: rung, count: count)
path.move(to: layout.point(radius: inner, angle: angle))
path.addLine(to: layout.point(radius: outer, angle: angle))
}
return path
}
}
Four instances, one per visual weight — plain rungs, labelled rungs, emphasised rungs, and the lit one:
ticks(plain, inner: .tickInner, outer: .tickOuter)
.stroke(.white.opacity(0.45), style: .init(lineWidth: 1.5, lineCap: .round))
ticks(labelled, inner: .tickInner, outer: .tickOuter)
.stroke(.white.opacity(0.85), style: .init(lineWidth: 2, lineCap: .round))
ticks([index], inner: .activeTickInner, outer: .activeTickOuter)
.stroke(.orange, style: .init(lineWidth: 3, lineCap: .round))
The lit rung is drawn longer and thicker, so it reads at a glance in bright sun rather than only under scrutiny. And unlabelled rungs still earn their ink: the ladder's length is information, and a thumb aims at ticks rather than at text.
Step 7Labels that thin out
Ten shutter speeds fit on the arc. Twenty-four focal lengths do not. A dense physical dial solves this by engraving every nth value, and so does this — but a plain stride breaks as soon as some rungs have earned a label of their own.
In NaturalCam the framings that use a lens's full sensor are always engraved. On a long ladder a stride label lands right next to one of those and the two numbers print on top of each other. Ours were 105 and 135 overprinting a native 100.
So placement is greedy rather than arithmetic — the ones that must not be dropped go down first and keep their space, then the rest fill in wherever there is room:
static func labelledRungs(count: Int, required: Set<Int>, maxLabels: Int) -> Set<Int> {
guard count > 0 else { return [] }
guard maxLabels > 0 else { return required }
let minimumGap = max(1, Int((Double(count) / Double(maxLabels)).rounded(.up)))
var placed = required.filter { (0..<count).contains($0) }
for rung in 0..<count where !placed.contains(rung) {
let clashes = placed.contains { abs($0 - rung) < minimumGap }
if !clashes { placed.insert(rung) }
}
return placed
}
maxLabels comes from the arc's own length, so a wheel that shrank on a smaller phone automatically engraves fewer values:
static func maxLabels(atRadius radius: CGFloat) -> Int {
let arcLength = radius * CGFloat(sweepDegrees * .pi / 180)
return max(3, Int(arcLength / 34)) // ~34pt each; a four-digit label is ~26 wide
}
The currently selected rung is always in required, so the value
you are changing is always the one you can read. That is why the demo above
re-engraves as you drag.
What a tick is engraved with is not what the panel reads
The 34 in that function is a width budget, and it assumes a
label about 26 points wide. Spell a shutter speed out as
1/2000 and you blow through it — at the top of the arc, where
the rungs converge, the label overlaps its neighbour. Which is what the
demo on this page did until it was pointed at the app's own formatter.
So the dial gets the denominator alone, the way a shutter dial is actually engraved, and the readout beside it spells the value out in full:
/// `1/8000` reads as `8000`; a second or longer keeps its seconds mark.
static func shutter(_ seconds: Double) -> String {
guard seconds > 0 else { return "—" }
if seconds < 1 { return "\(Int((1 / seconds).rounded()))" }
...
}
/// `1600` reads as `1.6k`, the way a Fujifilm ISO dial does.
static func iso(_ iso: Float) -> String { ... }
Two strings per value, not one. Drag the wheel above and watch the ticks
say 125 while the panel says 1/125 — that split is
load-bearing, not decoration.
Step 8Hit testing only the band
A wheel drawn in the corner still occupies a big rectangle, and a
naive .gesture on it swallows every touch in that rectangle —
including the shutter button in the hole at the middle.
contentShape takes any Shape, and we already have the right one:
.contentShape(
AnnulusSector(layout: layout,
innerRadius: Metrics.bandInner,
outerRadius: Metrics.bandOuter)
)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { move(to: $0.location, layout: layout) }
)
minimumDistance: 0 so a tap on a rung selects it, with
no slop threshold to drag through first. Once the drag has begun it keeps
receiving points even outside the shape, which is what you want — your thumb
wanders off the band constantly and the value should hold rather than drop.
Stacking two wheels
For concentric rings, make contentShape the union of both annuli
and disambiguate on distance from the pivot — decided once, at
value.startLocation, then latched for the rest of the drag so a
sloppy sweep cannot jump rings mid-gesture:
private func band(at point: CGPoint, layout: Layout) -> Band? {
let radius = WheelGeometry.radius(at: point, pivot: layout.pivot) / layout.scale
if abs(radius - Unit.outerTrack) <= Unit.outerBandWidth / 2 { return .outer }
if abs(radius - Unit.innerTrack) <= Unit.innerBandWidth / 2 { return .inner }
return nil
}
DragGesture(minimumDistance: 0)
.onChanged { value in
guard let band = activeBand ?? self.band(at: value.startLocation, layout: layout)
else { return }
if activeBand == nil { activeBand = band; haptics.prepare() }
move(band, to: value.location, layout: layout)
}
.onEnded { _ in activeBand = nil }
Note the / layout.scale: the hit test runs in unit
space, the same numbers the metrics are declared in, so shrinking the control
cannot drift the touch targets away from the ink.
Step 9The detent
private func move(to point: CGPoint, layout: Layout) {
let angle = WheelGeometry.angle(at: point, pivot: layout.pivot, mirrored: mirrored)
let next = WheelGeometry.index(forAngle: angle, count: labels.count)
guard next != index else { return }
index = next
haptics.impactOccurred(intensity: 0.6)
}
Four lines, and they are what makes the whole thing feel like a dial instead of a slider:
-
guard next != index else { return }— no write, no haptic, no re-render unless the value actually changed.onChangedfires at display rate; without this guard you get 120 taps a second and a burning Taptic Engine. - One tick per rung crossed, so sweeping fast feels fast. That is the physical metaphor doing real work: you can count rungs by feel without looking, which matters when the thing you are looking at is the shot.
-
UIImpactFeedbackGenerator(style: .rigid)atintensity: 0.6..rigidis a click;.softis a thud. Callprepare()when the drag starts, not on every tick — it warms the engine so the first click is not late.
Step 10Fitting the space it is given
The control places its own pivot relative to whatever rectangle it lands in, and scales the whole unit geometry to fit:
init(size: CGSize, mirrored: Bool, multiplier: CGFloat = 1) {
let x = mirrored ? Metrics.pivotInsetFromEdge
: size.width - Metrics.pivotInsetFromEdge
let pivot = CGPoint(x: x, y: size.height - Metrics.pivotInsetFromBottom)
self.pivot = pivot
self.mirrored = mirrored
// Whichever runs out first: the room above the pivot, or the room
// between it and the far edge.
self.scale = WheelGeometry.scale(
forAvailableRadius: min(pivot.y, mirrored ? size.width - pivot.x : pivot.x)
) * multiplier
}
Radii are declared as one group of constants at scale 1 and multiplied through a single choke point, so the parts cannot drift out of proportion with each other. The scale has a floor:
static func scale(forAvailableRadius available: CGFloat) -> CGFloat {
guard available > 0 else { return 1 }
return (available / Metrics.extent).clamped(to: 0.62...1.0)
}
Below about 0.62 the ticks are closer together than a thumb can resolve. Past that point it stops shrinking and lets the top of the arc clip instead — a cramped wheel you can still use beats a tidy one you cannot. There is a ceiling of 1.0 too: given a huge screen it stays thumb-sized rather than growing into a dinner plate.
The separate multiplier is a user setting in the app, five steps
of a tenth from 0.8 to 1.2. Thumbs differ by more than phones do; the size
that lets one person reach the far end of the arc puts the near end under
someone else's palm. It multiplies the fit rather than replacing it, so
1.0 is exactly the computed layout.
| Part | Value |
|---|---|
| Sweep | −10° → 100° |
| Band centre / width | 120 / 42 |
| Labels | 112 |
| Ticks, inactive | 127 → 137 |
| Ticks, active | 124 → 141 |
| Furthest ink | 141 — thumb reach ≈ 140 |
| Pivot inset, edge / bottom | 58 / 62 |
| Inner ring, if you stack two | track 74, width 40 |
| Scale range | 0.62 – 1.0, × 0.8–1.2 user size |
Testing it
Because the geometry is pure, the tests read like statements about the product rather than about the code:
func testAngleAndIndexAreInverses() {
for index in 0..<12 {
let angle = WheelGeometry.angle(forIndex: index, count: 12)
XCTAssertEqual(WheelGeometry.index(forAngle: angle, count: 12), index)
}
}
/// A thumb that slides past the end of the arc holds the last rung. A wrap
/// would run the shutter from 1/8000 round to a second mid-shot.
func testSweepingPastEitherEndHoldsTheEndRung() {
XCTAssertEqual(WheelGeometry.index(forAngle: -80, count: 10), 0)
XCTAssertEqual(WheelGeometry.index(forAngle: 170, count: 10), 9)
}
/// One thumb has to cover both rings without the grip changing.
func testBothRingsSitWithinOneThumbSweep() {
XCTAssertLessThanOrEqual(WheelGeometry.Metrics.extent, 145)
}
That last one is a layout constraint expressed as a unit test, which is the pattern worth stealing above all the trigonometry. The ergonomic budget is the thing that quietly erodes — someone adds a ring, someone nudges a radius, and six months later the control needs two hands and nobody can say which commit did it.
The whole file
Everything above, plus the drawing code and a working
#Preview, in one file with no dependencies beyond SwiftUI. Drop
it into an app target or a Swift Playground and it runs.
1. Put the pivot in the corner, outside the control.
Everything else is consequence.
2. Absolute, not accumulated — where the thumb is
is the value.
3. Clamp, never wrap.
4. Keep the maths out of the views. It is the only part
that is hard to get right and the only part that is easy to test.
5. Detent on change only. One haptic per rung crossed,
none otherwise.
The wheels are the reason NaturalCam has no mode picker: two arcs and a badge say what the camera is doing from across the room, the way a film body's dials do. If you want to see them in context, that is the shooting screen.