number_flow_flutter 0.1.0 copy "number_flow_flutter: ^0.1.0" to clipboard
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.

example/lib/main.dart

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'),
          ],
        ),
      ],
    );
  }
}
5
likes
160
points
41
downloads

Documentation

API reference

Publisher

verified publisherbriankariuki.com

Weekly Downloads

Beautiful animated numbers for Flutter — digit-by-digit rolling transitions with locale-aware formatting, springs, and motion blur.

Repository (GitHub)
View/report issues

Topics

#animation #number #counter #text #ui

License

MIT (license)

Dependencies

flutter, intl

More

Packages that depend on number_flow_flutter