countman

High-performance counter, countdown & elapsed animations for Flutter — driven by ONE shared vsync ticker, not per-widget timers. Built for high-concurrency timing.

English · 简体中文

pub.flutter-io.cn license Demo

▶ Live Demo — Counter · Countdown · Elapsed, all widgets, all APIs.

  • No Timer.periodic, no per-widget AnimationController — a single SchedulerBinding.scheduleFrameCallback drives everything.
  • 🚀 Built for high concurrency — the 100th live counter/timer costs the same as the 1st; the ticker auto-idles when nothing is animating.
  • 🎨 Counter · Countdown · Elapsed, each with text / ring / bar / odometer / dial / flip-card renderers, per-widget styles, controllers and providers.

Screenshots

Counter Countdown Elapsed
Counter demo Countdown demo Elapsed demo
Text / ring / bar / odometer / animated Text / ring / bar / dial / card Stopwatch, precise, provider

Captured from the example app — try it live at the demo site.


Why countman?

Most counter packages give every widget its own AnimationController (or a Timer.periodic). With N counters on screen you pay N frame-callback registrations, N timers, and N separate animation lifecycles — none of them aware of each other.

countman reverses this: one SchedulerBinding.scheduleFrameCallback drives every instance. The ticker is idle between animations (auto-stops when all tasks finish) and wakes up on demand. Adding a hundredth counter costs the same as adding the first.

Countman (1 scheduleFrameCallback)
  ├── Counter    — interpolates numbers from → to (every frame)
  ├── Countdown  — wall-clock deadline timers (interval-gated)
  └── Elapsed    — wall-clock elapsed timers (interval-gated)

Each engine is a CountmanPlugin; you can register more than one instance of each to isolate independent "groups".


Installation

dependencies:
  countman: ^0.1.0
import 'package:countman/countman.dart';

Performance

Approach Frame callbacks Timer allocations
N AnimationControllers N N vsync listeners
N Timer.periodic N timers
countman 1 0

Measured at 94 concurrent AnimatedCounter instances (0 → 999,999,999):

  • Raster: 8–11 ms — RepaintBoundary keeps each counter in its own layer.
  • Build: ~2 ms — CustomPainter path skips widget instantiation entirely.
  • Startup spike is spread across frames with StartScheduler batching.

Head-to-head vs other packages

50 concurrent countdowns, Windows desktop profile mode, 15 s measurement window per library, run back-to-back in one session (display at 120 Hz). FPS = frames actually rendered ÷ elapsed; UI/raster = per-frame thread time; CPU = share of one core, sampled externally from the OS process; RSS = resident set size. Lower is better except FPS/jank.

(50 个并发倒计时,Windows 桌面 profile 模式,每库测量 15 s,同一会话依次运行, 显示器 120 Hz。CPU 为单核占用率,从操作系统进程外部采样。除 FPS/jank 外均越低越好。)

Card / slide mode — countman CardCountdown(slide) vs slide_countdown ^2.0.2:

metric countman CardCountdown slide slide_countdown
FPS (frames / 15 s) 121.7 (1826) 32.5 (488)
UI ms avg / p99 0.80 / 2.12 1.32 / 4.39
raster ms avg / p99 0.83 / 1.47 1.05 / 1.76
jank frames 0 0
RSS avg / peak (MB) 130.2 / 137.3 130.3 / 135.4
CPU (1 core) 26.1 % 10.0 %

countman drives the slide+scale+opacity transition every vsync (fully smooth, cheaper per frame), so it renders far more frames and costs more total CPU; slide_countdown repaints only during its once-per-second slide bursts — lower CPU, but burstier cadence and pricier frames. Both are jank-free and use the same memory.

(countman 每帧驱动滑动+缩放+透明动画,完全顺滑、单帧更便宜,因此帧数更多、总 CPU 更高;slide_countdown 仅在每秒滑动瞬间重绘——CPU 更低,但帧节奏更突发、单帧更贵。 两者均无卡顿,内存相同。)

Text mode — countman TextCountdown vs stop_watch_timer ^3.2.2 (driving a Text via StreamBuilder):

metric countman TextCountdown stop_watch_timer
FPS (frames / 15 s) 120.9 (1813) 120.1 (1801)
UI ms avg / p99 0.10 / 0.16 0.16 / 0.66
raster ms avg / p99 0.37 / 0.59 0.31 / 0.58
jank frames 0 0
RSS avg / peak (MB) 113.8 / 116.0 113.9 / 116.4
CPU (1 core) 12.1 % 18.8 %

For plain-text countdowns the single shared ticker + markNeedsPaint costs ~35 % less CPU than 50 independent stop_watch_timer streams (12.1 % vs 18.8 % of a core) with steadier per-frame UI time; memory is identical.

(纯文本倒计时下,单一共享 ticker + markNeedsPaint 比 50 个独立 stop_watch_timer 流省约 35% CPU(单核 12.1% vs 18.8%),单帧 UI 耗时更稳; 内存相同。)

Reproduce with example/lib/benchmark_page.dart: flutter run --profile -d windows --dart-define=BENCH_LIB=countmanCard (also slide / countmanText / stopWatch).


Counter

Number-interpolation widgets. They animate from → to on the shared ticker. All of them accept from / to / duration (default 1000 ms) / curve (default Curves.easeOut) / allowNegative (default false, clamps to ≥ 0) / plugin / controller (CounterValueController) plus lifecycle callbacks onUpdate / onComplete / onReady / onStart / onCancel, and animateOnce (see Advanced).

TextCounter

Drop-in text counter with optional prefix/suffix.

TextCounter(to: 9999)                                        // "9999"
TextCounter(to: 9999, prefix: '¥', style: const TextCounterStyle(
  textStyle: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)))
TextCounter(to: 9999, prefixWidget: const Icon(Icons.star), suffix: ' pts')
TextCounter(to: 1234.56, fractionDigits: 2)                  // "1234.56"
TextCounter(to: 1234.56, formatter: (v) => v.toStringAsFixed(2))
Parameter Default Description
to required Target value
from 0 Start value
formatter integer String Function(double) — wins over fractionDigits
fractionDigits Decimal places when no formatter
style TextCounterStyle (alias of CountmanTextStyle)
prefix/suffix Plain text; prefixWidget/suffixWidget win
semanticsLabel Fixed screen-reader label
repaintBoundary false Isolate repaint layer

RingCounter

Circular arc that fills toward a goal: progress = (value − from) / (to − from).

RingCounter(
  to: 100,
  style: const RingCounterStyle(size: 80, strokeWidth: 10),
  center: const TextCounter(to: 100, suffix: '%'),
)

Visuals live in RingCounterStyle (alias of RingStyle). Also supports painterBuilder: (context, progress) => CustomPainter for a fully custom arc.

BarCounter

Linear progress bar filling toward a goal.

BarCounter(to: 100, style: const BarCounterStyle(
  width: 240, height: 12, gradient: LinearGradient(colors: [Colors.blue, Colors.green])))

Visuals live in BarCounterStyle (alias of BarStyle); also painterBuilder.

OdometerCounter

Mechanical-odometer sliding digits, drawn by a self-contained CustomPainter (no third-party package). The ones digit scrolls continuously while higher digits tick on integer carry.

OdometerCounter(
  to: 9999,
  style: const OdometerCounterStyle(
    numberTextStyle: TextStyle(fontSize: 40),
    letterWidth: 24,
  ),
  groupSeparator: ',',       // text drawn every 3 digits
)

OdometerCounter(from: 9999, to: 100)               // decreasing, no leading zeros
OdometerCounter(to: 500, bounceOvershoot: 0.35)    // spring overshoot per digit
Parameter Default Description
style OdometerCounterStyle (numberTextStyle, letterWidth 20, verticalOffset 20, fadeEnabled, digitAlignment, crossAxisAlignment, prefixStyle, suffixStyle, padding, decoration)
groupSeparator String drawn every 3 digits
slideCurve Easing on the per-digit slide
bounceOvershoot 0.0 Overshoot magnitude per ones-digit transition
prefix/suffix/prefixWidget/suffixWidget Affixes

AnimatedCounter

Full-featured rolling-digit counter: composable transitions, stagger, compact notation, decimals, digit grouping, color tinting and programmatic control. Backed by a persistent CustomPainter — zero widget builds per frame (the AnimatedCounterBuilder variant uses the widget-tree path instead).

AnimatedCounter(value: 9999)

AnimatedCounter(
  value: 1000000,
  duration: const Duration(seconds: 2),
  transition: CounterTransition.slide,   // .slide·.slideScale·.slideBlur·.rotate·.flip·.flipFade
  staggerDelay: const Duration(milliseconds: 30),
  staggerDirection: StaggerDirection.rightToLeft,
  thousandSeparator: ',',
  style: const AnimatedCounterStyle(
    textStyle: TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
    increasingColor: Colors.green, decreasingColor: Colors.red,
  ),
)

// Currency: prefix + grouping pattern (grouping [3]=USD, [4]=CNY, [3,2]=INR)
AnimatedCounter(value: 1234.56, prefix: r'$', fractionDigits: 2,
    thousandSeparator: ',', groupingPattern: const [3])   // $1,234.56

// Compact notation
AnimatedCounter(value: 1200000, compactNotation: true)  // "1.2M"

// International numerals
AnimatedCounter(value: 2025, numeralSystem: NumeralSystem.devanagari)

Key parameters:

Parameter Default Description
value Target value (or drive via controller)
controller AnimatedCounterController
duration 300 ms Animation duration
curve Curves.linear Easing curve
transition CounterTransition.slide Composable look: presets .slide·.slideScale·.slideBlur·.rotate·.flip·.flipFade, or build one from a CounterMotion (none/slide/rotate/flip) plus scale/fade/blur modifiers, e.g. CounterTransition(motion: CounterMotion.none, scale: true)
fast false Single-step per digit: each column moves ONE slot old→new (e.g. 1000→9999 slides 1→9 once) instead of the full cascading roll. Works with every transition; painter and widget paths both supported.
fractionDigits 0 Decimal places
wholeDigits 1 Minimum integer digit slots
hideLeadingZeroes true Hide leading zeros
thousandSeparator e.g. ','
groupingPattern [3] Digit grouping ([3, 2] for INR, [4] for CNY)
decimalSeparator '.' Decimal point character
staggerDelay Per-digit stagger offset
staggerDirection rightToLeft leftToRight or rightToLeft
compactNotation false Show 1200000 as 1.2M
compactAbbreviations K/M/B/T Custom compact labels (Map<num,String>)
numeralSystem latin easternArabic·persian·devanagari·bengali
showPositiveSign false Animated + for positive values
flipDirection AxisDirection.up Digit scroll direction
reverseDuration / reverseCurve Timing when animating backwards
startDelay Delay before starting
speedMultiplier 1.0 Scale all durations
triggerHaptics false Selection click on digit change
autoEaseThreshold 100000 Auto easeInOut for large linear ranges
repaintBoundary true Isolate repaint layer
style AnimatedCounterStyle (text/affix/separator styles, alignment, padding, increasingColor/decreasingColor/colorFadeDuration, decoration)
painterBuilder Custom CounterPainter subclass factory

AnimatedCounterBuilder

Same engine as AnimatedCounter but exposes digitBuilder / digitTransitionBuilder so you can render each digit with your own widget (always uses the widget-tree path — reserve for a handful of counters).

AnimatedCounterBuilder(
  value: 1234,
  digitBuilder: (context, digit, style) => Text('$digit', style: style),
)

CounterBuilder

Low-level driver. Exposes the raw animated double via builder — build anything from it. The cached child is passed through untouched each frame.

CounterBuilder(
  to: 9999,
  duration: const Duration(seconds: 2),
  curve: Curves.easeOut,
  builder: (context, value, child) => Text(value.toInt().toString(),
      style: const TextStyle(fontSize: 48)),
)

valueTransform maps the value before it reaches builder; onUpdate still sees the raw value.

CounterValueController

Imperative control for the counter family (TextCounter, RingCounter, BarCounter, OdometerCounter, CounterBuilder).

final ctrl = CounterValueController();
TextCounter(to: 0, controller: ctrl);

ctrl.update(to: 9999, duration: const Duration(seconds: 1)); // retarget from current
ctrl.pause();
ctrl.resume();
ctrl.cancel();
ctrl.value;        // current animated value
ctrl.isAnimating;  // running (not paused, not done)
ctrl.isPaused;
ctrl.isDone;

AnimatedCounterController

Richer controller for AnimatedCounter / AnimatedCounterBuilder.

final ctrl = AnimatedCounterController(initialValue: 0);
AnimatedCounter(controller: ctrl, value: 0);

ctrl.animateTo(9999);   // animate to a value
ctrl.jumpTo(9999);      // instant, no animation
ctrl.pause();
ctrl.resume();
ctrl.stop();
ctrl.restart();
ctrl.repeat(reverse: true);
ctrl.reverse();
ctrl.status;            // AnimationStatus
ctrl.addStatusListener(listener);

Countdown

Wall-clock deadline timers. Every countdown widget accepts to — a DateTime, Duration, int (ms since epoch) or ISO-8601 String — resolved to an absolute deadline so background pauses and frame drops never cause drift. Shared knobs: plugin, precise, controller (CountdownController), onComplete, onTick, threshold + onThreshold, and lifecycle onReady/onStart/onCancel/ onPause/onResume.

TextCountdown

Drop-in countdown text. const-constructible when to is a Duration.

TextCountdown(
  to: const Duration(minutes: 5),
  formatter: CountdownFormat.ms,
  style: const TextCountdownStyle(
    textStyle: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
)

TextCountdown(to: DateTime(2026, 1, 1), formatter: CountdownFormat.dhms)

RingCountdown

Arc ring that drains from full to empty (progress = remaining / total). A leading thumb dot is on by default so slow countdowns visibly move each tick.

RingCountdown(
  to: const Duration(minutes: 2),
  style: const RingCountdownStyle(size: 100, strokeWidth: 10),
  center: const TextCountdown(to: Duration(minutes: 2), formatter: CountdownFormat.ms),
)

BarCountdown

Linear progress bar that shrinks as time elapses.

BarCountdown(
  to: const Duration(minutes: 1),
  style: const BarCountdownStyle(
    width: 250, height: 10,
    gradient: LinearGradient(colors: [Colors.green, Colors.yellow, Colors.red]),
    borderRadius: Radius.circular(5),
  ),
)

DialCountdown

Analog dial with four concentric rings (ticks, two decorative arcs, inner progress ring). In the final minute the lit elements shift green → yellow → red.

DialCountdown(
  to: const Duration(minutes: 5),
  style: const DialCountdownStyle(size: 200, glow: true),
  builder: (context, parts) => Text(
    '${parts.minutes.toString().padLeft(2, '0')}:'
    '${parts.seconds.toString().padLeft(2, '0')}',
    style: const TextStyle(color: Colors.white, fontSize: 28),
  ),
)

DialCountdownStyle fields: size (200), clockwise, redAt (3), yellowAt (10), colors (DialColors), ticks (DialTicksConfig), arcA/arcB (DialArcConfig), inner (DialInnerConfig), glow, and explicit showTicks/showArcA/showArcB/showInner flags, plus centerAlignment / padding / decoration. builder fills the center; painterBuilder: (context, parts) => CustomPainter replaces the whole dial.

CardCountdown

Flip-card display; each time unit (H/M/S) is a card that animates on digit change. One AnimationController per card drives a single CustomPainter — digit changes never rebuild the widget tree.

CardCountdown(to: const Duration(hours: 1, minutes: 30))

CardCountdown(
  to: DateTime(2026, 12, 31),
  labels: const ['H', 'M', 'S'],       // pass null to hide labels
  separator: ':',
  showHours: true,                     // null = auto (shown when ≥ 1 h)
  style: const CardCountdownStyle(
    splitDigits: true,
    transitionType: CountdownType.slide,   // calendar · slide · flip
    scaleEffect: SlideEffect.both,
    opacityEffect: SlideEffect.enter,
    cardColor: Color(0xFF212121),
  ),
)

CardCountdownStyle fields: splitDigits, cardWidth (56) / cardHeight (76), digitGap / unitGap, cardColor, transitionType (CountdownType.calendar/slide/flip), scaleEffect / opacityEffect (SlideEffect.none/enter/exit/both), scaleFactor (1.5), perspective (0.006, flip only), textStyle / labelStyle / separatorStyle, padding / decoration. Widget-level: duration (450 ms), curve (linear), repaintBoundary. See also CardCountdownProvider.

CountdownBuilder

Low-level driver exposing the remaining time as TimeParts.

CountdownBuilder(
  duration: const Duration(minutes: 5),   // or use `to:` for a deadline
  builder: (context, parts, child) => Text(CountdownFormat.ms(parts)),
)

CountdownController

final ctrl = CountdownController();
CountdownBuilder(duration: const Duration(minutes: 2), controller: ctrl,
  builder: (_, parts, __) => Text(CountdownFormat.ms(parts)));

ctrl.pause();
ctrl.resume();
ctrl.reset();                                     // back to original duration
ctrl.reset(duration: const Duration(seconds: 30)); // override duration
ctrl.cancel();
ctrl.remaining;  // Duration
ctrl.isPaused;
ctrl.isDone;

Elapsed

Open-ended stopwatch timers — count up from zero indefinitely until removed or cancelled. Same plugin / precise / controller (ElapsedController) / onTick / threshold + onThreshold / lifecycle callbacks as Countdown.

TextElapsed

TextElapsed()                                  // 00:00, 00:01, 00:02, …
TextElapsed(formatter: CountdownFormat.hms)
TextElapsed(prefix: '⏱ ', style: const TextElapsedStyle(
  textStyle: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)))

ElapsedBuilder

ElapsedBuilder(
  builder: (context, parts, child) => Text(CountdownFormat.hms(parts)),
)

ElapsedController

final ctrl = ElapsedController();
TextElapsed(controller: ctrl);

ctrl.pause();
ctrl.resume();
ctrl.reset();     // back to zero, then resume
ctrl.cancel();
ctrl.elapsed;     // Duration
ctrl.isPaused;

Formatters

CountdownFormat provides String Function(TimeParts) formatters used by every countdown/elapsed text widget (via formatter:).

Formatter Example Notes
CountdownFormat.hms 01:23:45 Always shows hours
CountdownFormat.ms 03:07 Minutes may exceed 59
CountdownFormat.msTenths 00:09.7 Tenths of a second — pair with precise: true
CountdownFormat.msMillis 00:09.327 Full ms precision — pair with precise: true
CountdownFormat.dhms 2d 03:04:05 Whole days when ≥ 1 day, else hms
CountdownFormat.dhm 2d 03:04 Days + hours + minutes (no seconds)
CountdownFormat.auto adaptive ≥1d → dhms · ≥1h → hms · <10s → msTenths · else ms

You can also write your own: formatter: (t) => '${t.totalMinutes}m ${t.seconds}s'.


Styling

Every visual widget takes a style: object; the old loose visual params (size, strokeWidth, color, width, height, gradient, borderRadius, textStyle, …) have been folded into these. Every style also carries decoration + padding for a container background / border / rounded corners.

  • CountmanTextStyle — for text widgets. Aliases: TextCounterStyle, TextCountdownStyle, TextElapsedStyle. (textStyle, affix styles, decoration, padding.)

RingStyle (aliases RingCounterStyle / RingCountdownStyle)

size, strokeWidth, trackStrokeWidth, color, trackColor, gradient, trackGradient, startAngle, strokeCap, clockwise, sweepAngle (< 2π makes a partial-arc gauge), showTrack, backgroundColor, centerAlignment, and a thumb dot: showThumb (default on for RingCountdown, off for RingCounter), thumbColor, thumbRadius, plus padding / decoration.

const RingCounterStyle(
  size: 120, strokeWidth: 12,
  sweepAngle: 4.71,                     // ~270° gauge
  startAngle: 2.36,
  gradient: SweepGradient(colors: [Colors.blue, Colors.cyan]),
  showThumb: true,
)

BarStyle (aliases BarCounterStyle / BarCountdownStyle)

width, height, trackHeight, color, trackColor, gradient, trackGradient, borderRadius, borderRadiusGeometry, fillFromStart, showTrack, vertical (fill along the vertical axis), plus padding / decoration.

Other style objects: OdometerCounterStyle, DialCountdownStyle, CardCountdownStyle, AnimatedCounterStyle (documented with their widgets above). All styles are immutable with copyWith and merge.


Providers

Providers push default configuration (and an optional shared group) down to descendant widgets. A widget resolves each value as its own property > provider > built-in default.

Provider Configures
CounterProvider counter family — duration, curve, allowNegative, textStyle, color, trackColor, repaintBoundary, animateOnce, per-component *Style, plugin, onGroupReady/onAllComplete
CountdownProvider countdown family — formatter, textStyle, color, trackColor, repaintBoundary, animateOnce, per-component *Style (incl. cardCountdownStyle / dialCountdownStyle), plugin, group callbacks
ElapsedProvider elapsed family — formatter, textStyle, textElapsedStyle, plugin, group callbacks
CountmanProvider all three families at once (nests the above)
CardCountdownProvider CardCountdown defaults + a shared glyph (TextPainter) cache across a subtree of cards
CountmanProvider(
  textStyle: const TextStyle(fontSize: 24),
  color: Colors.teal,
  formatter: CountdownFormat.hms,
  child: MyPage(),   // TextCounter / RingCountdown / TextElapsed inside inherit these
)

onGroupReady fires when a group goes idle → active (first task enqueued); onAllComplete fires when it goes active → idle (last task leaves).


Advanced

The Counter / Countdown / Elapsed engines

Widgets are convenience wrappers; you can drive the engines directly. Each is a CountmanPlugin with a name and (for Countdown/Elapsed) an interval in ms (1000 = once per second, 0 = every frame).

// Top-level helpers on the auto-registered default instances:
final h  = counter(CounterOptions(to: 100, onUpdate: (v) => print(v)));
final cd = countdown(CountdownOptions(duration: const Duration(minutes: 1),
    onUpdate: (parts) => print(CountdownFormat.ms(parts))));
final el = elapsed(ElapsedOptions(onUpdate: (parts) => print(parts.inSeconds)));

// Default instances (auto-registered on first access):
defaultCounter; defaultCountdown; defaultElapsed;
defaultCountdownMs; defaultElapsedMs;   // interval: 0, used by precise widgets

// A custom group for isolation:
final auction = Countdown(name: 'auction', interval: 1000);
Countman.use(auction);                  // register (duplicate names ignored)
CountdownBuilder(duration: ..., plugin: auction, builder: ...);

Countman.start();   // usually implicit — plugins request frames when they add tasks
Countman.stop();    // pause the frame loop (tasks preserved)
Countman.destroy(); // stop + dispose every plugin

Register groups at module or long-lived-state level. Countman.use ignores duplicate names, so a group re-created inside initState on a reset would never receive onAttach and would throw on first add().

Millisecond precision (precise: true)

For sub-second formatters (msTenths / msMillis), set precise: true — the widget drives itself on the shared every-frame group (defaultCountdownMs / defaultElapsedMs, interval: 0) without you hand-wiring a plugin.

TextCountdown(
  to: const Duration(seconds: 10),
  precise: true,
  formatter: CountdownFormat.msMillis,   // 00:09.327
)

ElapsedBuilder(
  precise: true,
  builder: (_, parts, __) => Text(CountdownFormat.msTenths(parts)),
)

precise is ignored when you pass an explicit plugin.

Animate-once (list-friendly)

In a lazy list, a counter re-runs its entrance animation every time it scrolls back into view. Set animateOnce: true and a stable ValueKey: the entrance plays only the first time that key is seen under a provider; later rebuilds jump straight to the value.

CounterProvider(
  animateOnce: true,
  child: ListView(children: [
    for (final row in rows)
      TextCounter(key: ValueKey(row.id), to: row.amount),
  ]),
)

The registry lives on the provider, so it survives scroll-out/in. A widget's own animateOnce overrides the provider default.

Batch startup (StartScheduler)

When a dense grid of AnimatedCounters starts in one frame, the cold-start cost can blow the frame budget. StartScheduler spreads starts across frames.

StartScheduler.instance.defaultBatchSize = 5;   // ≤ 5 starts per frame
setState(() => _target = 999);

// Per-group override:
StartScheduler.instance.groupBatchSize[myCounter] = 10;

Always call StartScheduler.instance.cancel(this) in dispose() for widgets that enqueue, to release the closure.

Injectable clock (countdownClock)

Countdown/Elapsed read time through a replaceable () → DateTime. Override it in tests to advance time without real delays:

var fakeNow = DateTime(2024);
countdownClock = () => fakeNow;
fakeNow = fakeNow.add(const Duration(seconds: 3));   // "3 seconds pass"

TimeParts

The value object every countdown/elapsed builder receives. It is mutated in place each tick (one per task, zero per-frame allocation) — read the ints you need synchronously; don't retain it across frames.

  • Components: days, hours (0–23), minutes (0–59), seconds (0–59), millis (0–999)
  • Totals: totalHours, totalMinutes, totalSeconds, and Duration-style inDays/inHours/…/inMicroseconds
  • value (raw Duration), total (countdown denominator, null for elapsed), progress (0–1)
  • parts — live read-only [d, h, m, s, ms] view

Custom painters (painterBuilder)

RingCounter/RingCountdown/BarCounter/BarCountdown take painterBuilder: (context, progress) => CustomPainter; DialCountdown takes (context, parts) => CustomPainter; AnimatedCounter takes a CounterPainterBuilder. Every built-in painter (exported from painter/painter.dart) has public, individually-overridable drawing methods so you can subclass one instead of starting from scratch.


Performance tips

  • repaintBoundary — on (default for most) gives each widget its own compositor layer. Great for a handful; for dense grids (>~10) set it false and let one ancestor layer cover everything.
  • Batch grid startup with StartScheduler (above).
  • Avoid digitBuilder / digitTransitionBuilder at scale — they force the widget path (~0.85 ms/digit/frame) instead of the CustomPainter path.
  • blur and flip transition types always use the widget path — avoid in large grids.
  • Use interval: 1000 (the default) for HH:mm:ss displays; reach for precise:/interval: 0 only when you actually show sub-second digits.

Credits / Attributions

flip_counter_plus

  • Repository: github.com/Itsxhadi/flip_counter_plus
  • License: MIT
  • Role: AnimatedCounter's DigitColumn (and the overall structure) is adapted from AnimatedFlipCounter. Major changes:
    • AnimationController (per-instance vsync) replaced by the Counter engine on the shared Countman ticker.
    • Per-frame setState replaced by a persistent CounterPainter driven by a ValueNotifier repaint trigger — no widget build cost per frame.
    • Roll transition changed from Positioned (layout pass) to Transform.translate + ClipRect (compositor only).
    • All-nines target adjustment added to avoid degenerate digit patterns.

OdometerCounter no longer depends on the external odometer package — it is drawn by a self-contained CustomPainter bundled with countman.


Contributing

Issues and PRs are very welcome — bug reports, feature ideas, performance findings, docs, and new demos for the example app (which powers the live demo) all help.

  • 🐛 Open an issue
  • 🔧 Send a PR — please run dart analyze and flutter test (from countman/) before submitting.
  • 🎨 Adding a demo? Drop a page under example/lib/ and wire it into the home hub in example/lib/main.dart.

License

MIT — see LICENSE.

Libraries

countman