How to Implement a Custom Flutter Loader with Real-Time Progress Updates
Feed real-time progress to Flutter's built-in indicators using StreamBuilder or ValueListenableBuilder, leverage ProgressIndicatorTheme to share AnimationController instances for synchronized animations, and extend the base ProgressIndicator class with a custom CustomPainter only when necessary to preserve accessibility and performance.
The Flutter framework provides a highly optimized, layered architecture for progress indicators that makes real-time updates efficient and accessible. According to the flutter/flutter source code, the ProgressIndicator family in packages/flutter/lib/src/material/progress_indicator.dart separates concerns between widget API, theme configuration, and low-level painting to minimize rebuilds and GPU draw calls.
Understanding the ProgressIndicator Architecture
Flutter's progress indicator system is built on four distinct layers designed for performance and extensibility:
- Widget API Layer – The abstract
ProgressIndicatorbase class inpackages/flutter/lib/src/material/progress_indicator.dartdefines the public contract, including thevalue,valueColor, and semantic properties. - Concrete Implementations –
LinearProgressIndicatorandCircularProgressIndicator(located inpackages/flutter/lib/src/material/linear_progress_indicator.dartandpackages/flutter/lib/src/material/circular_progress_indicator.dart) extend this base to createStatefulWidgetinstances that handle determinate (fixed value) and indeterminate (animated) states. - Theme Layer –
ProgressIndicatorThemeinpackages/flutter/lib/src/material/progress_indicator_theme.dartsupplies defaults and enables sharing a singleAnimationControlleracross multiple widgets to keep animations synchronized. - Painting Layer – Private
CustomPaintersubclasses (_LinearProgressIndicatorPainterand_CircularProgressIndicatorPainter) handle all canvas drawing. These painters are reused every frame, ensuring the widget tree remains tiny while the GPU batches draw operations efficiently.
Choosing Between Determinate and Indeterminate Modes
Selecting the correct mode prevents unnecessary animation overhead and provides accurate user feedback.
Determinate loaders display exact progress by supplying a double in the range [0.0, 1.0] to the value property. When value is non-null, the widget rebuilds only when the value changes, and the painter draws a static bar representing completion percentage.
Indeterminate loaders indicate ongoing activity without a specific completion percentage. To activate this mode, leave value as null. The widget automatically creates or obtains an AnimationController to drive a repeating animation loop.
Streaming Real-Time Progress Data
For network downloads, file I/O, or long-running computations, use reactive programming patterns to update the loader without rebuilding the entire UI tree.
Stream-based updates are ideal for network requests or file operations that emit periodic progress events. Wrap your indicator in a StreamBuilder<double> that listens to a stream emitting values between 0.0 and 1.0. This isolates rebuilds to the indicator widget only.
ValueNotifier updates work well for synchronous progress tracking within the UI thread. Use ValueListenableBuilder<double> to listen to a ValueNotifier that your background task updates.
Isolate compatibility – When performing heavy computation (e.g., image processing or data parsing), run the work in an Isolate or via the compute function, and use a Stream or SendPort to transmit progress updates back to the main thread for UI rendering.
class RealTimeFileDownloader extends StatelessWidget {
const RealTimeFileDownloader({super.key, required this.progressStream});
final Stream<double> progressStream;
@override
Widget build(BuildContext context) {
return StreamBuilder<double>(
stream: progressStream,
initialData: 0.0,
builder: (context, snapshot) {
final progress = snapshot.data!.clamp(0.0, 1.0);
return LinearProgressIndicator(
value: progress,
color: Theme.of(context).colorScheme.secondary,
semanticsLabel: 'File download',
semanticsValue: '${(progress * 100).round()}%',
);
},
);
}
}
Synchronizing Animations with ProgressIndicatorTheme
When displaying multiple indeterminate loaders simultaneously, avoid visual clutter by synchronizing their animations using a shared AnimationController provided via ProgressIndicatorThemeData.
The framework allows you to inject a single controller into the theme, which all descendant indicators automatically adopt. This reduces memory allocation (eliminating per-widget controllers) and ensures perfect visual synchronization.
class SyncedLoaders extends StatefulWidget {
const SyncedLoaders({super.key});
@override
State<SyncedLoaders> createState() => _SyncedLoadersState();
}
class _SyncedLoadersState extends State<SyncedLoaders>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: LinearProgressIndicator.defaultAnimationDuration,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ProgressIndicatorTheme(
data: ProgressIndicatorThemeData(controller: _controller),
child: const Column(
children: [
LinearProgressIndicator(),
SizedBox(height: 24),
CircularProgressIndicator(),
],
),
);
}
}
As implemented in packages/flutter/lib/src/material/circular_progress_indicator.dart, this pattern is explicitly recommended in the "Animation synchronization" documentation blocks to maintain consistent motion across the UI.
Extending ProgressIndicator for Custom Visuals
Create a custom loader only when the design cannot be achieved through theme properties like color or strokeWidth. Subclass ProgressIndicator rather than building from scratch to inherit the framework's controller management, semantics handling, and theme integration.
Override the createState method to manage your own AnimationController when indeterminate, and provide a custom CustomPainter that receives the current progress value via the _effectiveValue getter. Reuse the existing _buildSemanticsWrapper method to maintain accessibility support.
class DiagonalProgressIndicator extends ProgressIndicator {
const DiagonalProgressIndicator({
super.key,
super.value,
super.backgroundColor,
super.color,
super.semanticsLabel,
super.semanticsValue,
});
@override
State<DiagonalProgressIndicator> createState() =>
_DiagonalProgressIndicatorState();
}
class _DiagonalProgressIndicatorState extends State<DiagonalProgressIndicator>
with SingleTickerProviderStateMixin {
@override
Widget build(BuildContext context) {
final progress = widget.value ?? 0.0;
return CustomPaint(
painter: _DiagonalPainter(
progress,
widget.color ?? Theme.of(context).colorScheme.primary,
),
);
}
}
Ensuring Accessibility and Performance
Accessibility – Always provide a descriptive semanticsLabel explaining what operation is loading. For determinate indicators, the framework automatically calculates percentage announcements via semanticsValue, but you can override this for custom descriptions (e.g., "3 of 5 files downloaded").
Performance – Never place progress indicators inside setState-heavy parent widgets unless absolutely necessary. Keep the indicator in its own StatelessWidget or use const constructors where possible. When updating progress, ensure values are clamped to [0.0, 1.0] to prevent unnecessary layout calculations.
Summary
- Use determinate mode (
valueset) for known progress amounts and indeterminate mode (valuenull) for unknown durations. - Implement real-time updates via
StreamBuilderorValueListenableBuilderto localize rebuilds and avoidsetStateon large widgets. - Share a single
AnimationControllerthroughProgressIndicatorThemeto synchronize multiple indeterminate loaders and reduce resource usage. - Extend
ProgressIndicatorand override only the painter when building custom visuals to retain framework accessibility and theme support. - Reference
packages/flutter/lib/src/material/progress_indicator.dartand related files to understand the internal_effectiveValuehandling and animation synchronization patterns.
Frequently Asked Questions
How do I update a Flutter loader in real-time without rebuilding the entire screen?
Wrap your LinearProgressIndicator or CircularProgressIndicator in a StreamBuilder<double> or ValueListenableBuilder<double>. These widgets isolate state changes to the indicator itself, preventing parent widgets from rebuilding when progress increments. According to the Flutter framework source, this pattern keeps the widget tree minimal and allows the private _LinearProgressIndicatorPainter to batch draw calls efficiently.
What is the difference between determinate and indeterminate progress indicators in Flutter?
A determinate indicator displays exact completion percentage when you provide a double between 0.0 and 1.0 to the value property, causing the widget to rebuild only on value changes. An indeterminate indicator animates continuously when value is null, using an internal AnimationController to drive the visual loop without representing specific progress, as implemented in packages/flutter/lib/src/material/linear_progress_indicator.dart.
How can I synchronize multiple progress indicators to animate together?
Provide a shared AnimationController via ProgressIndicatorThemeData(controller: yourController) to a ProgressIndicatorTheme widget wrapping your indicator subtree. All descendant LinearProgressIndicator and CircularProgressIndicator widgets will use this controller instead of creating their own, ensuring lock-step animation and reducing memory overhead per the synchronization patterns documented in packages/flutter/lib/src/material/circular_progress_indicator.dart.
When should I create a custom painter instead of using the built-in indicators?
Create a custom CustomPainter only when your design requires visuals impossible to achieve through ProgressIndicatorTheme properties like gradients, shapes, or stroke patterns. Extend the base ProgressIndicator class to inherit the framework's controller logic and semantics handling, then override the painter in your state's build method while calling super._buildSemanticsWrapper to maintain accessibility compliance.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →