number_flow_flutter 0.1.0
number_flow_flutter: ^0.1.0 copied to clipboard
Beautiful animated numbers for Flutter — digit-by-digit rolling transitions with locale-aware formatting, springs, and motion blur.
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:number_flow_flutter/number_flow_flutter.dart';
void main() => runApp(const ExampleApp());
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'NumberFlow',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const DemoPage(),
);
}
}
class DemoPage extends StatefulWidget {
const DemoPage({super.key});
@override
State<DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage> {
final _rand = Random();
int _count = 0;
double _price = 42.99;
double _pct = 0.42;
// Scrub demo: a ValueNotifier drives NumberFlow.scrub directly from the
// slider, without setState per tick — the widget repaints and rolls only the
// digits that change (see NumberFlow.scrub / Phase 4).
final ValueNotifier<num> _scrub = ValueNotifier<num>(0);
// Tunable parameters.
bool _springy = true;
double _stiffness = 170;
double _damping = 20;
double _spinMs = 900; // used when spring is off (curve duration)
double _motionBlur = 4.0;
double _blurThreshold = 0.5;
double _blurVelocityScale = 8.0;
double _digitSquish = 0.3;
double _staggerMs = 35;
double _wheelSpacing = 1.2;
bool _continuous = false;
bool _tabular = true;
NumberFlowSpring? get _spring => _springy ? NumberFlowSpring(mass: 1, stiffness: _stiffness, damping: _damping) : null;
TimingConfig get _spinTiming => TimingConfig(
duration: Duration(milliseconds: _spinMs.round()),
curve: const NumberFlowCurve(),
);
Duration get _stagger => Duration(milliseconds: _staggerMs.round());
_FlowTuning get _tuning => _FlowTuning(
spring: _spring,
spinTiming: _spinTiming,
motionBlur: _motionBlur,
blurThreshold: _blurThreshold,
blurVelocityScale: _blurVelocityScale,
digitSquish: _digitSquish,
stagger: _stagger,
wheelSpacing: _wheelSpacing,
);
@override
void dispose() {
_scrub.dispose();
super.dispose();
}
void _randomize() {
setState(() {
_count = _rand.nextInt(100001); // 0–100,000
_price = _rand.nextDouble() * 10000;
_pct = _rand.nextDouble();
});
}
/// +1 nudges every preview (like Randomize, but by a small step) so the
/// currency, percent, compact, and scientific cells all animate together.
void _increment() {
setState(() {
_count += 1;
_price += 1;
_pct = (_pct + 0.01).clamp(0.0, 1.0);
});
}
NumberFlow _flow(num value, {NumberFlowFormat? format, double fontSize = 52}) => NumberFlow(
value: value,
format: format,
locale: 'en_US',
style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600, color: Colors.black),
spring: _spring,
spinTiming: _spinTiming,
motionBlur: _motionBlur,
motionBlurThreshold: _blurThreshold,
motionBlurVelocityScale: _blurVelocityScale,
digitSquish: _digitSquish,
stagger: _stagger,
wheelSpacing: _wheelSpacing,
continuous: _continuous,
tabularNums: _tabular,
// continuous needs a directional trend to spin the lower digits.
trend: _continuous ? 1 : null,
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('NumberFlow — Flutter')),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ─── Preview grid (three columns, compact cells) ───
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 2,
crossAxisSpacing: 2,
childAspectRatio: 2.5,
children: [
_PreviewCell(label: 'Counter', child: _flow(_count, fontSize: 48)),
_PreviewCell(
label: 'Currency',
child: _flow(
_price,
format: const NumberFlowFormat.currency(currencyCode: 'USD', symbol: r'$'),
fontSize: 48,
),
),
_PreviewCell(
label: 'Percent',
child: _flow(_pct, format: const NumberFlowFormat.percent(), fontSize: 48),
),
_PreviewCell(
label: 'Compact',
child: _flow(_count, format: const NumberFlowFormat.compact(maxFraction: 1), fontSize: 48),
),
_PreviewCell(
label: 'Scientific',
child: _flow(_count, format: const NumberFlowFormat.scientific(maxFraction: 2), fontSize: 48),
),
// Stopwatch lives in the grid too. Self-contained: owns its
// Ticker + setState, so the ~100Hz tick rebuilds only its cell.
_PreviewCell(
label: 'Stopwatch',
child: _Stopwatch(tuning: _tuning, fontSize: 48),
),
],
),
const SizedBox(height: 24),
// ─── Scrub preview (own slider; kept full-width) ───
Center(
child: Column(
children: [
const _Label('Scrub (drag the slider below)'),
NumberFlow.scrub(
value: _scrub,
format: const NumberFlowFormat.currency(currencyCode: 'USD', symbol: r'$'),
locale: 'en_US',
style: const TextStyle(fontSize: 52, fontWeight: FontWeight.w600, color: Colors.black),
spring: _spring,
spinTiming: _spinTiming,
motionBlur: _motionBlur,
motionBlurThreshold: _blurThreshold,
motionBlurVelocityScale: _blurVelocityScale,
digitSquish: _digitSquish,
stagger: _stagger,
wheelSpacing: _wheelSpacing,
tabularNums: true, // steady width for smooth scrubbing
),
],
),
),
const SizedBox(height: 12),
// Scrubbing slider: 1000 steps of 1, updating the value rapidly
// as you drag — like a chart scrubber or price slider.
// Drives the notifier directly — no setState per tick. The scrub
// widget listens and rolls only the digits that change.
ValueListenableBuilder<num>(
valueListenable: _scrub,
builder: (context, scrub, _) => _Slider(
label: 'Scrub value (0–1000, step 1)',
value: scrub.toDouble(),
min: 0,
max: 1000,
divisions: 1000, // step size = 1
display: '\$${scrub.round()}',
onChanged: (v) => _scrub.value = v.round(),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton.tonal(onPressed: _increment, child: const Text('+1')),
const SizedBox(width: 12),
FilledButton(onPressed: _randomize, child: const Text('Randomize')),
],
),
const Divider(height: 40),
// ─── Controls ───
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Spring (iOS feel)'),
subtitle: const Text('Off = NumberFlow deceleration curve'),
value: _springy,
onChanged: (v) => setState(() => _springy = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Continuous (odometer)'),
subtitle: const Text('Lower digits sweep a full cycle on carry'),
value: _continuous,
onChanged: (v) => setState(() => _continuous = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Tabular numbers'),
subtitle: const Text('Uniform digit width'),
value: _tabular,
onChanged: (v) => setState(() => _tabular = v),
),
_Slider(
label: 'Motion blur (frosted, max sigma px)',
value: _motionBlur,
min: 0,
max: 16,
display: _motionBlur.toStringAsFixed(1),
onChanged: (v) => setState(() => _motionBlur = v),
),
_Slider(
label: 'Blur velocity scale (lower = blur ramps in sooner)',
value: _blurVelocityScale,
min: 1,
max: 16,
display: _blurVelocityScale.toStringAsFixed(1),
onChanged: (v) => setState(() => _blurVelocityScale = v),
),
_Slider(
label: 'Blur threshold (speed gate, digits/sec)',
value: _blurThreshold,
min: 0,
max: 4,
display: _blurThreshold.toStringAsFixed(2),
onChanged: (v) => setState(() => _blurThreshold = v),
),
_Slider(
label: 'Digit squish (horizontal compress while rolling)',
value: _digitSquish,
min: 0,
max: 0.6,
display: _digitSquish.toStringAsFixed(2),
onChanged: (v) => setState(() => _digitSquish = v),
),
_Slider(
label: 'Stagger (ms/digit)',
value: _staggerMs,
min: 0,
max: 120,
display: '${_staggerMs.round()} ms',
onChanged: (v) => setState(() => _staggerMs = v),
),
_Slider(
label: 'Hidden-digit spacing (× line height)',
value: _wheelSpacing,
min: 1,
max: 2.5,
display: '${_wheelSpacing.toStringAsFixed(2)}×',
onChanged: (v) => setState(() => _wheelSpacing = v),
),
// Spring controls — always editable. Adjusting either
// auto-enables spring mode (they have no effect in curve mode).
_Slider(
label: _springy ? 'Spring stiffness' : 'Spring stiffness (enables spring)',
value: _stiffness,
min: 40,
max: 400,
display: _stiffness.round().toString(),
onChanged: (v) => setState(() {
_stiffness = v;
_springy = true;
}),
),
_Slider(
label: _springy ? 'Spring damping (lower = bouncier)' : 'Spring damping (enables spring)',
value: _damping,
min: 6,
max: 40,
display: _damping.round().toString(),
onChanged: (v) => setState(() {
_damping = v;
_springy = true;
}),
),
// Curve-only control (springs have no fixed duration — use
// stiffness/damping instead).
Opacity(
opacity: _springy ? 0.4 : 1,
child: IgnorePointer(
ignoring: _springy,
child: _Slider(
label: _springy ? 'Spin duration — turn Spring OFF to use' : 'Spin duration (curve mode)',
value: _spinMs,
min: 200,
max: 2000,
display: '${_spinMs.round()} ms',
onChanged: (v) => setState(() => _spinMs = v),
),
),
),
const SizedBox(height: 8),
Center(
child: TextButton.icon(
onPressed: () => setState(() {
_springy = true;
_stiffness = 170;
_damping = 20;
_spinMs = 900;
_motionBlur = 2.5;
_blurThreshold = 0.5;
_blurVelocityScale = 8.0;
_digitSquish = 0.3;
_staggerMs = 35;
_wheelSpacing = 1.2;
_continuous = false;
_tabular = false;
}),
icon: const Icon(Icons.restart_alt),
label: const Text('Reset to iOS defaults'),
),
),
],
),
),
),
);
}
}
class _Slider extends StatelessWidget {
const _Slider({
required this.label,
required this.value,
required this.min,
required this.max,
required this.display,
required this.onChanged,
this.divisions,
});
final String label;
final double value;
final double min;
final double max;
final String display;
final ValueChanged<double> onChanged;
final int? divisions;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
Text(display, style: Theme.of(context).textTheme.labelMedium),
],
),
Slider(value: value.clamp(min, max), min: min, max: max, divisions: divisions, onChanged: onChanged),
],
);
}
}
class _Label extends StatelessWidget {
const _Label(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(text, style: Theme.of(context).textTheme.labelMedium),
);
}
/// A bordered card for the preview grid: a caption above a centered NumberFlow.
class _PreviewCell extends StatelessWidget {
const _PreviewCell({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(12)),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Label(label),
const SizedBox(height: 4),
// FittedBox keeps wide values (e.g. large compact/scientific) inside
// the cell without overflow.
Expanded(child: Center(child: child)),
],
),
);
}
}
/// The shared animation-tuning knobs, bundled so they can be passed as one prop
/// and compared cheaply — a subtree only rebuilds when a knob actually changes.
@immutable
class _FlowTuning {
const _FlowTuning({
required this.spring,
required this.spinTiming,
required this.motionBlur,
required this.blurThreshold,
required this.blurVelocityScale,
required this.digitSquish,
required this.stagger,
required this.wheelSpacing,
});
final NumberFlowSpring? spring;
final TimingConfig spinTiming;
final double motionBlur;
final double blurThreshold;
final double blurVelocityScale;
final double digitSquish;
final Duration stagger;
final double wheelSpacing;
@override
bool operator ==(Object other) =>
other is _FlowTuning &&
other.spring == spring &&
other.spinTiming.duration == spinTiming.duration &&
other.motionBlur == motionBlur &&
other.blurThreshold == blurThreshold &&
other.blurVelocityScale == blurVelocityScale &&
other.digitSquish == digitSquish &&
other.stagger == stagger &&
other.wheelSpacing == wheelSpacing;
@override
int get hashCode => Object.hash(spring, spinTiming.duration, motionBlur, blurThreshold, blurVelocityScale, digitSquish, stagger, wheelSpacing);
}
/// A self-contained stopwatch: owns its [Ticker] and elapsed state so the
/// ~100Hz centisecond tick rebuilds only this widget, never the parent page.
class _Stopwatch extends StatefulWidget {
const _Stopwatch({required this.tuning, this.fontSize = 52});
final _FlowTuning tuning;
final double fontSize;
@override
State<_Stopwatch> createState() => _StopwatchState();
}
class _StopwatchState extends State<_Stopwatch> with SingleTickerProviderStateMixin {
late final Ticker _ticker;
Duration _elapsed = Duration.zero;
Duration _base = Duration.zero;
bool _running = false;
@override
void initState() {
super.initState();
_ticker = createTicker((elapsed) {
setState(() => _elapsed = _base + elapsed);
});
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
void _toggle() {
setState(() {
if (_running) {
_ticker.stop();
_base = _elapsed; // freeze accumulated time
_running = false;
} else {
_ticker.start();
_running = true;
}
});
}
void _reset() {
setState(() {
_ticker.stop();
_elapsed = Duration.zero;
_base = Duration.zero;
_running = false;
});
}
@override
Widget build(BuildContext context) {
final t = widget.tuning;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TimeFlow.duration(
_elapsed,
showHours: _elapsed.inHours > 0,
showCentiseconds: true,
tabularNums: true,
style: TextStyle(fontSize: widget.fontSize, fontWeight: FontWeight.w600, color: Colors.black, fontFeatures: const [FontFeature.tabularFigures()]),
spring: t.spring,
spinTiming: t.spinTiming,
motionBlur: t.motionBlur,
motionBlurThreshold: t.blurThreshold,
motionBlurVelocityScale: t.blurVelocityScale,
digitSquish: t.digitSquish,
stagger: t.stagger,
wheelSpacing: t.wheelSpacing,
),
const SizedBox(height: 4),
// Compact controls that fit in a grid cell.
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
visualDensity: VisualDensity.compact,
onPressed: _toggle,
icon: Icon(_running ? Icons.pause : Icons.play_arrow),
tooltip: _running ? 'Stop' : 'Start',
),
IconButton(visualDensity: VisualDensity.compact, onPressed: _reset, icon: const Icon(Icons.restart_alt), tooltip: 'Reset'),
],
),
],
);
}
}