Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Added `CameraZoomDial`, an accessible compact/expanded camera zoom control with
configurable optical stops, focal-length labels, logarithmic wheel geometry,
touch-and-hold expansion, snapping, controlled or uncontrolled state, and a
measured pointer cutout that masks rotating ticks like the iPhone Camera UI.
- Added a camera-style example, zoom geometry regression tests, public type
coverage, and API documentation.

## [0.1.0] - 2026-08-30

### Added
Expand Down
101 changes: 92 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
[![CI](https://github.com/ngocdevv/dial-slider/actions/workflows/ci.yml/badge.svg)](https://github.com/ngocdevv/dial-slider/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/%40ngocdevv%2Fdial-slider.svg)](https://www.npmjs.com/package/@ngocdevv/dial-slider)

An animated, photo-style dial slider component for React Native and Expo. It
combines a gesture-driven ruler with single- or multi-preset adjustment controls,
independent values, progress rings, and accessible increment/decrement actions.
Animated, photo-style dial controls for React Native and Expo. The package
includes a multi-preset adjustment ruler and an iPhone-camera-style zoom wheel,
with UI-runtime gestures and accessible increment/decrement actions.

## Demo

![Dial Slider demo](./demo.gif)

## Features

- `CameraZoomDial` provides compact optical-stop buttons plus a logarithmic,
circular zoom scale that expands on touch-and-hold.
- Camera zoom motion follows the wheel tangent, spaces every zoom doubling by
20 degrees, snaps to the requested step, and reports a final value.
- One preset renders a centered adjustment tool; multiple presets render a
horizontally scrollable strip.
- Each preset keeps an independent value, range, initial value, icon, label,
Expand Down Expand Up @@ -83,6 +87,44 @@ export function App() {
The library package does not depend on Expo Router. `expo-linear-gradient` is
the only Expo module used by the component itself.

## Camera zoom wheel

Tap a compact stop to jump to it, or tap the selected stop to open the adjustable
wheel. Touch and hold the control, then drag left or right for continuous zoom.
In uncontrolled expansion mode, the wheel returns to its compact state 1.2
seconds after an interaction finishes.

```tsx
import { useState } from 'react';
import { CameraZoomDial, type CameraZoomStop } from '@ngocdevv/dial-slider';

const ZOOM_STOPS: readonly CameraZoomStop[] = [
{ value: 0.5, focalLength: '13MM' },
{ value: 1, focalLength: '26MM' },
{ value: 2 },
];

export function CameraZoomControl() {
const [zoom, setZoom] = useState(1);

return (
<CameraZoomDial
minZoom={0.5}
maxZoom={10}
step={0.1}
value={zoom}
zoomStops={ZOOM_STOPS}
onZoomChange={setZoom}
accessibilityLabel="Camera zoom"
/>
);
}
```

Use `defaultValue` instead of `value` for uncontrolled zoom. Use `expanded` and
`onExpandedChange` to own the compact/expanded state, or `defaultExpanded` when
the wheel should initially be open.

## Single preset

```tsx
Expand Down Expand Up @@ -176,6 +218,41 @@ export function PhotoAdjustments() {

## API reference

### `CameraZoomDialProps`

| Prop | Type | Default | Description |
| -------------------- | ----------------------------- | ------------------------ | ----------------------------------------------------------------------------------- |
| `minZoom` | `number` | `0.5` | Smallest positive zoom factor. Reversed and invalid ranges are normalized. |
| `maxZoom` | `number` | `10` | Largest zoom factor. |
| `step` | `number` | `0.1` | Visible, callback, snapping, and accessibility precision. |
| `value` | `number` | `undefined` | Controlled zoom factor. |
| `defaultValue` | `number` | `1` when in range | Initial uncontrolled zoom factor. |
| `zoomStops` | `readonly CameraZoomStop[]` | In-range `0.5`, `1`, `2` | Optical/quick stops; empty or invalid input falls back to the in-range defaults. |
| `expanded` | `boolean` | `undefined` | Controlled wheel expansion state. |
| `defaultExpanded` | `boolean` | `false` | Initial uncontrolled expansion state. |
| `onExpandedChange` | `(expanded: boolean) => void` | `undefined` | Reports requested compact/expanded state changes. |
| `onZoomChange` | `(zoom: number) => void` | `undefined` | Reports each crossed step during a drag or animated stop change. |
| `onInteractionStart` | `() => void` | `undefined` | Called when a wheel drag or quick-stop transition begins. |
| `onInteractionEnd` | `(zoom: number) => void` | `undefined` | Called with the final snapped value. |
| `formatValue` | `(zoom: number) => string` | Numeric value plus `x` | Formats the current visible and accessible value. |
| `accentColor` | `string` | `#FFD60A` | Pointer, active value, focal length, and selected compact label. |
| `surfaceColor` | `string` | 50% black | Expanded circular surface color. |
| `labelColor` | `string` | `#FFFFFF` | Inactive stop label color. |
| `disabled` | `boolean` | `false` | Disables drag, quick-stop, and accessibility value changes. |
| `accessibilityLabel` | `string` | `Camera zoom` | Adjustable wheel label. |
| `accessibilityHint` | `string` | Interaction instructions | Adjustable wheel hint. |
| `style` | `StyleProp<ViewStyle>` | `undefined` | Root style. Width determines the measured wheel geometry; height follows its ratio. |
| `testID` | `string` | `undefined` | Root identifier; stop IDs append `-stop-{value}`. |

### `CameraZoomStop`

| Field | Type | Default | Description |
| -------------- | -------- | ------------ | ------------------------------------------------------------------- |
| `value` | `number` | Required | Positive factor, normalized to the nearest configured `step`. |
| `label` | `string` | Numeric | Circular wheel label without an automatically appended `x`. |
| `compactLabel` | `string` | Camera style | Inactive compact label; fractions omit the leading zero by default. |
| `focalLength` | `string` | None | Optional equivalent focal length, such as `13MM` or `26MM`. |

### `DialSliderProps`

| Prop | Type | Default | Description |
Expand Down Expand Up @@ -211,6 +288,12 @@ export function PhotoAdjustments() {

### Callback semantics

- `CameraZoomDial` reports discrete `step` values while its scale moves. In
controlled mode, `value` remains the source of truth; in uncontrolled mode,
the component applies each proposal internally.
- Controlled `expanded` state is changed only by its owner. In uncontrolled
mode, the component expands for a wheel gesture and automatically collapses
after the interaction.
- `onPresetChange(presetId, value)` reports a user selection request and the
current value owned by that preset. Uncontrolled mode also reports an
automatic fallback when the active preset is removed or disabled.
Expand All @@ -234,12 +317,12 @@ of release performance.

## Accessibility

The active ruler exposes `adjustable`, its normalized range and current value,
disabled state, and increment/decrement actions. Preset buttons expose selected
and disabled state plus their values. Supply concise preset labels, use
`formatValue` for units, and provide an explicit `accessibilityLabel` when the
surrounding context is not obvious. Reanimated transitions honor the system
reduced-motion preference.
The expanded camera wheel and active adjustment ruler expose `adjustable`, their
normalized range and current value, disabled state, and increment/decrement
actions. Compact zoom stops and preset controls expose button selection and
disabled state. Supply concise labels, use `formatValue` for units, and provide
an explicit `accessibilityLabel` when the surrounding context is not obvious.
Reanimated transitions honor the system reduced-motion preference.

## Development

Expand Down
98 changes: 39 additions & 59 deletions apps/example/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import React, { useCallback, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import React from 'react';
import { ScrollView, StyleSheet, Text } from 'react-native';

import { DialSlider, type DialPreset } from '@ngocdevv/dial-slider';
import {
CameraZoomDial,
DialSlider,
type CameraZoomStop,
type DialPreset,
} from '@ngocdevv/dial-slider';

function PresetGlyph({
children,
Expand All @@ -17,11 +22,12 @@ function PresetGlyph({
);
}

/**
* Demo tool list. Length decides chrome:
* - 1 item → single centered tool + ruler
* - N items → horizontal preset strip + shared ruler
*/
const CAMERA_ZOOM_STOPS: readonly CameraZoomStop[] = [
{ value: 0.5, focalLength: '13MM' },
{ value: 1, focalLength: '26MM' },
{ value: 2 },
];

const PHOTO_PRESETS: readonly DialPreset[] = [
{
id: 'exposure',
Expand Down Expand Up @@ -82,42 +88,30 @@ const PHOTO_PRESETS: readonly DialPreset[] = [
];

export default function HomeScreen() {
const [activePreset, setActivePreset] = useState('highlights');
const [activeValue, setActiveValue] = useState(0);

const handlePresetChange = useCallback((presetId: string, value: number) => {
setActivePreset(presetId);
setActiveValue(value);
}, []);

const handleValueChange = useCallback((presetId: string, value: number) => {
setActivePreset(presetId);
setActiveValue(value);
}, []);

const activeLabel =
PHOTO_PRESETS.find((preset) => preset.id === activePreset)?.label ??
activePreset;

return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Photo adjustments</Text>
<Text style={styles.caption}>
{activeLabel} · {activeValue}
</Text>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
style={styles.container}
contentContainerStyle={styles.content}
>
<CameraZoomDial
minZoom={0.5}
maxZoom={10}
defaultValue={1}
defaultExpanded
zoomStops={CAMERA_ZOOM_STOPS}
accessibilityLabel="Camera zoom"
testID="camera-zoom-dial"
/>

<View style={styles.dialSurface}>
<DialSlider
presets={PHOTO_PRESETS}
initialPresetId="highlights"
onPresetChange={handlePresetChange}
onValueChange={handleValueChange}
backgroundColor="#000000"
/>
</View>
</View>
</View>
<DialSlider
presets={PHOTO_PRESETS}
initialPresetId="highlights"
backgroundColor="#000000"
testID="dial-slider"
/>
</ScrollView>
);
}

Expand All @@ -127,24 +121,10 @@ const styles = StyleSheet.create({
backgroundColor: '#000000',
},
content: {
flex: 1,
flexGrow: 1,
gap: 16,
justifyContent: 'center',
},
title: {
color: '#FFFFFF',
fontSize: 22,
fontWeight: '700',
textAlign: 'center',
},
caption: {
color: '#8E8E93',
fontSize: 14,
marginTop: 6,
marginBottom: 24,
textAlign: 'center',
},
dialSurface: {
width: '100%',
paddingVertical: 24,
},
glyph: {
color: '#E4E4E7',
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ module.exports = defineConfig([
// purity rules report false positives for these worklet-facing modules.
files: [
'packages/dial-slider/src/components/dial-slider/**/*.{ts,tsx}',
'packages/dial-slider/src/components/camera-zoom-dial/CameraZoomDial.tsx',
'packages/dial-slider/src/hooks/useDialRulerMotion.ts',
'packages/dial-slider/src/hooks/useCameraZoomMotion.ts',
],
rules: {
'react-hooks/refs': 'off',
Expand Down
9 changes: 6 additions & 3 deletions packages/dial-slider/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# @ngocdevv/dial-slider

Animated, photo-style dial slider for React Native and Expo with single- and
multi-preset modes, independent values, accessible actions, and UI-runtime
gesture motion.
Animated, photo-style dial controls for React Native and Expo: a single- or
multi-preset adjustment ruler plus an iPhone-camera-style logarithmic zoom wheel.
Both provide accessible actions and UI-runtime gesture motion.

```bash
bun add @ngocdevv/dial-slider
Expand All @@ -12,7 +12,10 @@ npx expo install expo-linear-gradient react-native-gesture-handler \

```tsx
import {
CameraZoomDial,
DialSlider,
type CameraZoomDialProps,
type CameraZoomStop,
type DialPreset,
type DialSliderProps,
} from '@ngocdevv/dial-slider';
Expand Down
5 changes: 4 additions & 1 deletion packages/dial-slider/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@ngocdevv/dial-slider",
"version": "0.1.0",
"description": "Animated photo-style dial slider component for React Native and Expo.",
"description": "Animated dial slider and camera zoom wheel components for React Native and Expo.",
"type": "commonjs",
"source": "./src/index.ts",
"main": "./lib/commonjs/index.js",
Expand Down Expand Up @@ -41,6 +41,9 @@
"expo",
"slider",
"dial",
"camera",
"zoom",
"wheel",
"reanimated",
"gesture-handler",
"photo-editor",
Expand Down
Loading