countman 0.1.0
countman: ^0.1.0 copied to clipboard
High-performance counter, countdown & stopwatch animations for Flutter. One shared vsync ticker drives every instance — no per-widget timers, built for high concurrency.
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 · 简体中文
▶ Live Demo — Counter · Countdown · Elapsed, all widgets, all APIs.
- ⚡ No
Timer.periodic, no per-widgetAnimationController— a singleSchedulerBinding.scheduleFrameCallbackdrives 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.
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
StartSchedulerbatching.
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(alsoslide/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 |
bounceElasticity |
4.0 |
Where the overshoot peak lands (≥ 1) |
prefix/suffix/prefixWidget/suffixWidget |
— | Affixes |
AnimatedCounter #
Full-featured rolling-digit counter: 7 transition types, stagger, compact
notation, decimals, currency grouping, color tinting and programmatic control.
Backed by a persistent CustomPainter — zero widget builds per frame (the
flip/blur types and the builder variant fall back to the widget path).
AnimatedCounter(value: 9999)
AnimatedCounter(
value: 1000000,
duration: const Duration(seconds: 2),
transitionType: CounterTransitionType.roll, // roll·fade·scale·fadeScale·rotate·flip·blur
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 factories (prefix + grouping preset, fractionDigits: 2)
AnimatedCounter.usd(value: 1234.56) // $1,234.56 — grouping [3]
AnimatedCounter.cny(value: 1234.56) // ¥1,234.56 — grouping [4]
AnimatedCounter.inr(value: 1234.56) // ₹1,234.56 — grouping [3, 2]
// 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 |
transitionType |
roll |
roll·fade·scale·fadeScale·rotate·flip·blur |
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.useignores duplicate names, so a group re-created insideinitStateon a reset would never receiveonAttachand would throw on firstadd().
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, andDuration-styleinDays/inHours/…/inMicroseconds value(rawDuration),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 itfalseand let one ancestor layer cover everything.- Batch grid startup with
StartScheduler(above). - Avoid
digitBuilder/digitTransitionBuilderat scale — they force the widget path (~0.85 ms/digit/frame) instead of the CustomPainter path. blurandfliptransition types always use the widget path — avoid in large grids.- Use
interval: 1000(the default) for HH:mm:ss displays; reach forprecise:/interval: 0only when you actually show sub-second digits.
Credits / Attributions #
flip_counter_plus #
- Repository: github.com/Itsxhadi/flip_counter_plus
- License: MIT
- Role:
AnimatedCounter'sDigitColumn(and the overall structure) is adapted fromAnimatedFlipCounter. Major changes:AnimationController(per-instance vsync) replaced by theCounterengine on the sharedCountmanticker.- Per-frame
setStatereplaced by a persistentCounterPainterdriven by aValueNotifierrepaint trigger — no widget build cost per frame. - Roll transition changed from
Positioned(layout pass) toTransform.translate + ClipRect(compositor only). - All-nines target adjustment added to avoid degenerate digit patterns.
OdometerCounterno longer depends on the externalodometerpackage — it is drawn by a self-containedCustomPainterbundled 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 analyzeandflutter test(fromcountman/) before submitting. - 🎨 Adding a demo? Drop a page under
example/lib/and wire it into the home hub inexample/lib/main.dart.
License #
MIT — see LICENSE.