Rotary Knob Dial
- Skia
- Reanimated
- Gesture Handler
- Haptics
A knob you turn by circling it. The gesture follows the angle of your finger around the centre rather than how far it travelled, so you can wind round as many times as you like, and the reading is that angle mapped onto a 300° sweep.
Turning by angle, not distance
Each move takes atan2 of the finger against the knob's centre and asks for the shortest way round from the previous angle. Wrapping that difference into ±π means crossing the seam at the back of the dial reads as one small step instead of a full turn backwards. The running total is then clamped to the sweep, so the knob stops at its ends.
const a = Math.atan2(e.y - g.cy, e.x - g.cx);const next = clampN( angleAccumulator(prevAngle.get(), a, rotation.get()), 0, KNOB_SWEEP_RAD,);rotation.set(next);prevAngle.set(a);// …// angleAccumulator(prevAngle, nextAngle, accumulated)let r = (nextAngle - prevAngle) % twoPi;if (r <= -Math.PI) r += twoPi;else if (r > Math.PI) r -= twoPi;return accumulated + r;A flick only counts sideways
Momentum uses the part of the release velocity that runs along the ring. The code builds the unit tangent at the point you let go, projects the pointer velocity onto it and divides by the radius to get radians per second. So a flick across the knob spins it and a flick toward the centre does nothing, which is how a real dial behaves.
const tx = -py / len;const ty = px / len;const tangential = e.velocityX * tx + e.velocityY * ty;const angularVel = tangential / r;rotation.set(withDecay({ velocity: angularVel, deceleration: DECAY_DECELERATION, clamp: [0, KNOB_SWEEP_RAD],}));Ticks that light before you reach them
The dial carries 24 detents. A tick behind the pointer is drawn lit, and the one ahead fades up across the last 4% of the sweep, so the ring brightens as you approach a notch rather than snapping on at it. Every fourth tick is drawn long. The haptic is separate, and fires only when the rounded detent index changes.
export function tickLight( tickFrac: number, currentFrac: number, feather: number = 0.04,): number { 'worklet'; if (tickFrac <= currentFrac) return 1; const ahead = tickFrac - currentFrac; if (ahead >= feather) return 0; return 1 - ahead / feather;}Tuning
- 0
- 100
- #37D67A
- On
<RotaryKnobDial params={{ min: 0, max: 100, glowColor: "#37D67A", momentum: true }} fireSignal={0} />