ruler_scrubber 1.0.0
ruler_scrubber: ^1.0.0 copied to clipboard
A performant, accessible ruler-style numeric scrubber for Flutter.
ruler_scrubber #
An accessible, performant ruler-style numeric input for Flutter. The ruler scrolls under a stationary needle, so a wide range stays scrubbable at a fine grain instead of being compressed into one screen width of slider track.
[A ruler scrubber being scrubbed: the card lights up, the price counts along with the ruler, and the flick coasts to a stop]
The card lights up under the finger, the value counts along with the ruler, and the flick carries on scrolling after the finger has gone.
[Three ruler scrubbers on a light card, the middle one lit up mid-scrub]
[The same three scrubbers on a dark card, the middle one lit up mid-scrub]
All three pictures are of the widget itself, rendered by
tool/screenshot_test.dart and
tool/animation_test.dart rather than captured by
hand. In the stills the middle scrubber is held mid-drag, which is why its card
and needle carry the accent colour.
Why a ruler #
A slider maps its whole range onto the width of its track, so on a range of
0…100 at a resolution of 0.01 a single pixel is worth several steps and the
exact value can only be reached by luck. A ruler decouples the two: the value
moves by tickStep per tick under the finger no matter how wide the range is,
and a flick coasts, so a distant value costs a gesture rather than a pixel of
travel.
Install #
flutter pub add ruler_scrubber
The package is built on material_ui,
the official Material Design library that used to live inside the SDK as
package:flutter/material.dart. If your app still imports the SDK copy, run
Flutter's own migration once and everything lines up:
dart fix --apply --code=migrate_design_widgets
Until you run it the scrubber still builds and scrubs normally, but it looks
for a material_ui theme your app does not yet provide and falls back to the
baseline Material palette rather than yours. Flutter's
MaterialUiCompatibilityBridge does not cover this direction — it carries a
modern theme down to legacy widgets, not the other way about. Passing an
explicit style skips the theme lookup altogether and is unaffected either
way.
Usage #
import 'package:ruler_scrubber/ruler_scrubber.dart';
RulerScrubber(
value: price,
min: 0,
max: 100,
step: 0.01, // the value snaps to whole pennies
tickStep: 0.02, // one tick of ruler is worth two of them
semanticLabel: 'Price',
formatValue: (value) => '${value.toStringAsFixed(2)} pounds',
onChanged: (value) => setState(() => price = value),
onChangeEnd: (value) => context.read<Quote>().recalculate(value),
)
The widget is presentation only: it reports the value it was scrubbed to and draws the value it is given. Hold that value yourself and pass it back in — setting it from anywhere other than the ruler runs the ruler to it.
Parameters #
| Parameter | Type | Description |
|---|---|---|
value |
double |
Where the ruler sits. Clamped into [min, max]. |
min, max |
double |
The ends of the range. |
tickStep |
double |
How much the value changes over one tick — the scale of the ruler, and so how fast it moves under the finger. |
step |
double? |
Granularity of the reported value. null scrubs continuously. |
onChanged |
ValueChanged<double> |
Every value the scrub passes through. |
onChangeEnd |
ValueChanged<double>? |
The value the ruler came to rest on, for work too expensive to run per frame. |
semanticLabel |
String |
Spoken name of the control. |
formatValue |
String Function(double)? |
Spoken form of the value. Defaults to two decimal places. |
style |
RulerScrubberStyle? |
Colours, card shape and shadows. Defaults to the ambient ThemeData. |
tickStep and step do different jobs and are worth setting separately.
tickStep is the feel of the control — how far the finger travels per unit of
value. step is the contract with your model — which values are legal. A
tickStep coarser than step scrubs quickly but still lands on exact values.
Styling #
Omit style and the scrubber derives accessible defaults from the surrounding
Material theme. Pass one to use design-system tokens instead:
RulerScrubberStyle(
shape: const StadiumBorder(side: BorderSide(width: 1.5)),
backgroundColor: tokens.surface,
borderColor: tokens.border,
activeBorderColor: tokens.accent,
minorTickColor: tokens.borderSubtle,
majorTickColor: tokens.textSecondary,
needleColor: tokens.textSecondary,
activeShadows: const [BoxShadow(blurRadius: 12, color: Color(0x22000000))],
activeNeedleShadows: const [BoxShadow(blurRadius: 6, color: Color(0x33000000))],
)
The card and needle take their active treatment while a scrub is in
progress — including the coast after a flick — so the field being edited is
obvious in a form full of them.
The border
shape takes any OutlinedBorder, so the scrubber can be given the same
corner as everything else on your screen and this package needs no opinion
about which corner that is:
shape: const RoundedRectangleBorder( // the default, radius 10
side: BorderSide(width: 1),
borderRadius: BorderRadius.all(Radius.circular(10)),
),
shape: const StadiumBorder(side: BorderSide(width: 1.5)), // a pill
shape: const ContinuousRectangleBorder(...), // a superellipse
shape: SmoothRectangleBorder(...), // figma_squircle
shape: const RoundedRectangleBorder(side: BorderSide.none), // no border
The shape's own side is drawn as given, except for its colour: that comes
from borderColor and activeBorderColor so the outline can light up while
the ruler is being scrubbed. Squircles work the same as anything else —
SmoothRectangleBorder is an OutlinedBorder — but they come from your
pubspec.yaml rather than this package's.
Fixed geometry (tick spacing, needle size, default card radius, animation
durations) lives in ruler_scrubber_metrics.dart as top-level constants, if
you need to line something else up with the ruler.
Accessibility #
The scrubber presents itself as a slider to the platform's assistive
technology: semanticLabel is its name, formatValue renders its value, and
the increase/decrease actions nudge by step — or by a twentieth of the range
when step is null. Scrubbing clicks once per tick via
HapticFeedback.selectionClick, and a ruler running to a value set elsewhere
stays silent.
Performance #
The ticks are painted from the scroll offset rather than laid out inside the
scrollable, so a range worth thousands of them costs the same frame as one
worth a dozen — only the handful under the viewport is ever drawn. The painter
repaints from the scroll position alone, behind a RepaintBoundary, so nothing
above the ruler rebuilds while a finger is on it. The end fade is a gradient on
the tick paint rather than a mask, which keeps a scrub off the compositor.
Example #
example/ is a runnable app showing a price, a stepped percentage
and a scrubber styled from explicit tokens, in both brightnesses.
cd example
flutter create . # generate the platform folders for your machine
flutter run
Development #
flutter analyze
flutter test
flutter test tool/screenshot_test.dart # regenerate doc/*.png
flutter test tool/animation_test.dart # regenerate doc/scrubbing.gif
The screenshots are rendered from the widget itself, so they cannot drift from what it actually looks like. The tool needs a system font to draw real text with; it looks for the macOS ones and fails loudly if it finds none.
License #
MIT — see LICENSE.