Last active
October 15, 2025 05:33
-
-
Save PlugFox/e486dcaf99d958973a1f1b1cddea789b to your computer and use it in GitHub Desktop.
Dots Loading Indicator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Dots Loading Indicator | |
| * https://gist.github.com/PlugFox/e486dcaf99d958973a1f1b1cddea789b | |
| * https://dartpad.dev?id=e486dcaf99d958973a1f1b1cddea789b | |
| * Mike Matiunin <[email protected]>, 14 October 2025 | |
| */ | |
| // ignore_for_file: curly_braces_in_flow_control_structures | |
| import 'dart:typed_data'; | |
| import 'dart:ui' as ui show PointMode; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart' show PipelineOwner, SemanticsConfiguration; | |
| import 'package:flutter/scheduler.dart' show Ticker; | |
| import 'package:meta/meta.dart' show internal; | |
| /// {@template dots_loading_indicator} | |
| /// DotsLoadingIndicator widget. | |
| /// A loading indicator with three animated dots that bounce up and down. | |
| /// Optionally, it can display a [text] next to the dots. | |
| /// The color of the dots can be customized with [color]. | |
| /// The speed of the animation can be adjusted with [speed]. | |
| /// The animation curve can be customized with [curve]. | |
| /// If [boundary] is true, the widget will be a repaint boundary. | |
| /// {@endtemplate} | |
| class DotsLoadingIndicator extends LeafRenderObjectWidget { | |
| /// Creates a DotsLoadingIndicator widget. | |
| /// | |
| /// {@macro dots_loading_indicator} | |
| const DotsLoadingIndicator({ | |
| this.text, | |
| this.color, | |
| this.speed = 1.0, | |
| this.curve = Curves.easeInOut, | |
| this.boundary = false, | |
| super.key, | |
| }); | |
| /// Optional text to display next to the loading indicator. | |
| final TextSpan? text; | |
| /// The color used for the animated dots. | |
| /// If null, it will use the text color or black. | |
| final Color? color; | |
| /// Speed of the animation. | |
| final double speed; | |
| /// Animation curve. | |
| final Curve curve; | |
| /// Whether to make this widget a repaint boundary. | |
| /// Defaults to false. | |
| final bool boundary; | |
| /// Helper method to compute the effective color for the dots. | |
| static Color _getEffectiveColor(BuildContext context, Color? color, TextSpan? text) { | |
| final defaultTextStyle = DefaultTextStyle.of(context).style; | |
| return color ?? text?.style?.color ?? defaultTextStyle.color ?? const Color(0xFF000000); | |
| } | |
| @override | |
| RenderObject createRenderObject(BuildContext context) { | |
| final effectiveColor = _getEffectiveColor(context, color, text); | |
| final textDirection = Directionality.maybeOf(context); | |
| return DotsLoadingIndicator$RenderObject() | |
| ..text = text | |
| ..speed = speed | |
| ..dotsPaint.color = effectiveColor | |
| ..curve = curve | |
| ..textDirection = textDirection ?? TextDirection.ltr | |
| ..boundary = boundary; | |
| } | |
| @override | |
| void updateRenderObject(BuildContext context, covariant DotsLoadingIndicator$RenderObject renderObject) { | |
| final effectiveColor = _getEffectiveColor(context, color, text); | |
| final textDirection = Directionality.of(context); | |
| final needsLayout = renderObject.text != text || renderObject.textDirection != textDirection; | |
| final needsRepaintOnly = | |
| renderObject.speed != speed || renderObject.curve != curve || renderObject.dotsPaint.color != effectiveColor; | |
| renderObject | |
| ..text = text | |
| ..speed = speed | |
| ..dotsPaint.color = effectiveColor | |
| ..curve = curve | |
| ..textDirection = textDirection | |
| ..boundary = boundary; | |
| if (needsLayout) { | |
| renderObject.markNeedsLayout(); | |
| } else if (needsRepaintOnly) { | |
| renderObject.markNeedsPaint(); | |
| } | |
| // Re-evaluate ticker state when inputs change (e.g., speed toggled to 0). | |
| renderObject.evaluateTicker(); | |
| } | |
| } | |
| @internal | |
| class DotsLoadingIndicator$RenderObject extends RenderBox with WidgetsBindingObserver { | |
| DotsLoadingIndicator$RenderObject() | |
| : dotsPaint = | |
| Paint() | |
| ..style = PaintingStyle.stroke | |
| ..strokeCap = StrokeCap.round | |
| ..blendMode = BlendMode.srcOver | |
| ..isAntiAlias = true, | |
| _dotsPosition = Float32List(6); | |
| // --- Animation constants --- // | |
| static const double dotPhaseOffset = 0.2; | |
| static const double dotJumpHeight = 0.3; | |
| static const double dotRadiusFactor = 0.15; | |
| static const double dotStrokeWidthFactor = 0.3; | |
| static const double dotSpacingFactor = 0.4; | |
| // --- Text layout constants --- // | |
| static const double paddingFactor = 0.2; | |
| static const double dotSizeWidthFactor = 0.3; | |
| static const double dotSizeSpacingFactor = 0.15; | |
| /// Optional text to display next to the loading indicator. | |
| TextSpan? text; | |
| /// The color used for the animated dots. | |
| Paint dotsPaint; | |
| /// Speed of the animation. | |
| double speed = 1.0; | |
| /// Animation curve. | |
| Curve curve = Curves.easeInOut; | |
| /// Text direction used by the internal TextPainter. | |
| TextDirection textDirection = TextDirection.ltr; | |
| /// Whether to make this widget a repaint boundary. | |
| /// Defaults to false. | |
| bool boundary = false; | |
| /// Animation loop ticker. | |
| Ticker? _ticker; | |
| /// Text painter for rendering the optional text. | |
| TextPainter? _textPainter; | |
| /// Padding between the text and the dots. | |
| double _padding = 0.0; | |
| /// Size of the dots area. | |
| Size _dotsSize = Size.zero; | |
| /// Positions of the dots. | |
| final Float32List _dotsPosition; | |
| /// Total amount of time passed since the animation loop was started. | |
| Duration _lastFrameTime = Duration.zero; | |
| /// Whether animations are enabled (not disabled by accessibility settings). | |
| bool get _animationsEnabled => | |
| !WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.disableAnimations && speed > 0.0; | |
| @override | |
| bool get isRepaintBoundary => boundary; | |
| @override | |
| bool get alwaysNeedsCompositing => false; | |
| @override | |
| bool get sizedByParent => false; | |
| @override | |
| void attach(PipelineOwner owner) { | |
| super.attach(owner); | |
| WidgetsBinding.instance.addObserver(this); | |
| _ensureTickerState(); | |
| } | |
| @override | |
| void detach() { | |
| _ticker?.dispose(); | |
| _ticker = null; | |
| _textPainter?.dispose(); | |
| _textPainter = null; | |
| WidgetsBinding.instance.removeObserver(this); | |
| super.detach(); | |
| } | |
| @override | |
| void didChangeAccessibilityFeatures() { | |
| super.didChangeAccessibilityFeatures(); | |
| _ensureTickerState(); | |
| } | |
| void _ensureTickerState() { | |
| if (_animationsEnabled) { | |
| _ticker ??= Ticker(_onTick, debugLabel: 'DotsLoadingIndicator'); | |
| if (!_ticker!.isActive) _ticker!.start(); | |
| } else { | |
| _ticker?.stop(); | |
| } | |
| } | |
| /// Public hook to re-evaluate ticker state when external inputs change. | |
| void evaluateTicker() => _ensureTickerState(); | |
| @override | |
| void performLayout() { | |
| super.size = computeDryLayout(constraints); | |
| } | |
| @override | |
| Size computeDryLayout(BoxConstraints constraints) { | |
| const defaultDotsSize = Size(18.0, 12.0); | |
| if (text case TextSpan span) { | |
| // Measure the text and calculate the size of the dots based on the font size | |
| final textPainter = _textPainter ??= TextPainter(textDirection: textDirection, maxLines: 1, ellipsis: '…'); | |
| textPainter | |
| ..textDirection = textDirection | |
| ..text = span | |
| ..layout(maxWidth: constraints.maxWidth); | |
| final fontSize = span.style?.fontSize ?? 12.0; | |
| _padding = fontSize * paddingFactor; | |
| _dotsSize = Size( | |
| (fontSize * dotSizeWidthFactor * 3) + (fontSize * dotSizeSpacingFactor * 2), // 3 dots + 2 spaces | |
| textPainter.height / 2, // dots should jump in the middle of the text | |
| ); | |
| var size = Size(textPainter.width + _padding + _dotsSize.width, textPainter.height); | |
| if (size.width > constraints.maxWidth) { | |
| _padding = 0.0; // no padding if text is too long | |
| _textPainter = null; // don't render text if too long | |
| size = _dotsSize = constraints.constrain(defaultDotsSize); | |
| } else { | |
| size = constraints.constrain(size); | |
| } | |
| _relayoutDots(); // layout dots positions | |
| return size; | |
| } else { | |
| // No text, just dots, constrain to a fixed size | |
| _textPainter = null; | |
| _padding = .0; | |
| _dotsSize = constraints.constrain(defaultDotsSize); | |
| _relayoutDots(); | |
| return _dotsSize; | |
| } | |
| } | |
| /// Relayout the dots positions `Float32List _dots` based on the current size. | |
| void _relayoutDots() { | |
| final elapsed = _lastFrameTime.inMicroseconds * .000001 * speed; | |
| final size = _dotsSize; | |
| final dotsPosition = _dotsPosition; | |
| for (var i = 0; i < 3; i++) { | |
| final phase = (elapsed - (i * dotPhaseOffset)) % 1.0; | |
| // Map to a 0->1->0 triangle wave and ease it with the provided curve for smoother motion. | |
| final localT = phase < 0.5 ? phase * 2 : (1.0 - phase) * 2; // 0..1..0 | |
| final eased = curve.transform(localT); | |
| final eased01 = eased.clamp(0.0, 1.0); // clamp to keep dots within expected bounds | |
| final dy = -eased01 * size.height * dotJumpHeight; | |
| final dx = i * size.width * dotSpacingFactor; | |
| final radius = size.height * dotRadiusFactor; | |
| dotsPosition | |
| ..[i * 2] = dx + radius | |
| ..[i * 2 + 1] = size.height / 2 + dy; | |
| } | |
| } | |
| /// This method is periodically invoked by the [_ticker]. | |
| void _onTick(Duration elapsed) { | |
| if (!attached) return; | |
| _lastFrameTime = elapsed; | |
| if (_dotsSize.isEmpty) return; | |
| _relayoutDots(); | |
| markNeedsPaint(); | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| if (size.isEmpty) return; | |
| final DotsLoadingIndicator$RenderObject( | |
| _padding: padding, | |
| _dotsSize: dotsSize, | |
| _textPainter: textPainter, | |
| _dotsPosition: dotsPosition, | |
| ) = this; | |
| final hasText = textPainter != null && textPainter.text != null; | |
| final hasDots = dotsSize != Size.zero; | |
| final hasPadding = hasText && hasDots && padding > 0; | |
| final canvas = context.canvas; | |
| canvas | |
| ..save() | |
| ..translate(offset.dx, offset.dy) | |
| ..clipRect(Offset.zero & size); | |
| // Draw the text if available | |
| if (hasText) textPainter.paint(canvas, Offset.zero); | |
| // Move to the start position of the dots | |
| if (hasPadding) canvas.translate(textPainter.width + padding, textPainter.height - dotsSize.height); | |
| // Draw the dots if available | |
| if (hasDots) | |
| canvas.drawRawPoints( | |
| ui.PointMode.points, | |
| _dotsPosition, | |
| dotsPaint..strokeWidth = dotsSize.height * dotStrokeWidthFactor, | |
| ); | |
| canvas.restore(); | |
| } | |
| @override | |
| void describeSemanticsConfiguration(SemanticsConfiguration config) { | |
| super.describeSemanticsConfiguration(config); | |
| if (!config.isSemanticBoundary) return; | |
| // Provide a simple label for assistive technologies. | |
| final label = text?.toPlainText().trim(); | |
| final effectiveLabel = label != null && label.isNotEmpty ? '$label...' : 'Loading...'; | |
| config | |
| ..isMergingSemanticsOfDescendants = true | |
| ..label = effectiveLabel | |
| ..liveRegion = true; // hint that it may be dynamic | |
| } | |
| } | |
| // ============================================================================ | |
| // Demo / Example Usage | |
| // ============================================================================ | |
| void main() => runApp(const DotsLoadingIndicatorDemo()); | |
| class DotsLoadingIndicatorDemo extends StatelessWidget { | |
| const DotsLoadingIndicatorDemo({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return const MaterialApp(debugShowCheckedModeBanner: false, home: DemoScreen()); | |
| } | |
| } | |
| class DemoScreen extends StatefulWidget { | |
| const DemoScreen({super.key}); | |
| @override | |
| State<DemoScreen> createState() => _DemoScreenState(); | |
| } | |
| class _DemoScreenState extends State<DemoScreen> { | |
| double _speed = 1.0; | |
| Curve _curve = Curves.easeInOut; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar(title: const Text('DotsLoadingIndicator Demo'), backgroundColor: Colors.blue), | |
| body: SingleChildScrollView( | |
| padding: const EdgeInsets.all(24.0), | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| // Example 1: Just dots | |
| _buildExample('Just Dots (default)', Center(child: const DotsLoadingIndicator())), | |
| // Controls | |
| Card( | |
| child: Padding( | |
| padding: const EdgeInsets.all(16.0), | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| Text('Speed: ${_speed.toStringAsFixed(1)}', style: const TextStyle(fontWeight: FontWeight.bold)), | |
| Slider( | |
| value: _speed, | |
| min: 0.0, | |
| max: 3.0, | |
| divisions: 30, | |
| label: _speed.toStringAsFixed(1), | |
| onChanged: (value) => setState(() => _speed = value), | |
| ), | |
| const SizedBox(height: 16), | |
| const Text('Animation Curve:', style: TextStyle(fontWeight: FontWeight.bold)), | |
| Wrap( | |
| spacing: 8, | |
| children: [ | |
| _buildCurveChip('easeInOut', Curves.easeInOut), | |
| _buildCurveChip('linear', Curves.linear), | |
| _buildCurveChip('bounceOut', Curves.bounceOut), | |
| _buildCurveChip('elasticOut', Curves.elasticOut), | |
| ], | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| const SizedBox(height: 32), | |
| // Example 2: Dots with text | |
| _buildExample( | |
| 'With Text', | |
| DotsLoadingIndicator( | |
| speed: _speed, | |
| curve: _curve, | |
| text: const TextSpan(text: 'Loading', style: TextStyle(fontSize: 16, color: Colors.black87)), | |
| ), | |
| ), | |
| // Example 3: Custom color | |
| _buildExample( | |
| 'Custom Color (Blue)', | |
| DotsLoadingIndicator( | |
| speed: _speed, | |
| curve: _curve, | |
| color: Colors.blue, | |
| text: const TextSpan(text: 'Processing', style: TextStyle(fontSize: 16, color: Colors.blue)), | |
| ), | |
| ), | |
| // Example 4: Large with styled text | |
| _buildExample( | |
| 'Large Styled Text', | |
| DotsLoadingIndicator( | |
| speed: _speed, | |
| curve: _curve, | |
| text: const TextSpan( | |
| text: 'Please wait', | |
| style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.purple), | |
| ), | |
| color: Colors.purple, | |
| ), | |
| ), | |
| // Example 5: Small | |
| _buildExample( | |
| 'Small Text', | |
| DotsLoadingIndicator( | |
| speed: _speed, | |
| curve: _curve, | |
| text: const TextSpan(text: 'Loading data', style: TextStyle(fontSize: 12, color: Colors.grey)), | |
| color: Colors.grey, | |
| ), | |
| ), | |
| // Example 6: Different colors | |
| _buildExample( | |
| 'Various Colors', | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
| children: [ | |
| DotsLoadingIndicator(speed: _speed, curve: _curve, color: Colors.red), | |
| DotsLoadingIndicator(speed: _speed, curve: _curve, color: Colors.green), | |
| DotsLoadingIndicator(speed: _speed, curve: _curve, color: Colors.orange), | |
| DotsLoadingIndicator(speed: _speed, curve: _curve, color: Colors.teal), | |
| ], | |
| ), | |
| ), | |
| // Example 7: With boundary | |
| _buildExample( | |
| 'As Repaint Boundary', | |
| DotsLoadingIndicator( | |
| speed: _speed, | |
| curve: _curve, | |
| boundary: true, | |
| text: const TextSpan(text: 'Optimized rendering', style: TextStyle(fontSize: 14, color: Colors.indigo)), | |
| color: Colors.indigo, | |
| ), | |
| ), | |
| // Example 8: Slow animation | |
| _buildExample( | |
| 'Slow Animation (speed=0.3)', | |
| const DotsLoadingIndicator( | |
| speed: 0.3, | |
| text: TextSpan(text: 'Slow motion', style: TextStyle(fontSize: 16, color: Colors.brown)), | |
| color: Colors.brown, | |
| ), | |
| ), | |
| // Example 9: Fast animation | |
| _buildExample( | |
| 'Fast Animation (speed=2.0)', | |
| const DotsLoadingIndicator( | |
| speed: 2.0, | |
| text: TextSpan(text: 'Fast pace', style: TextStyle(fontSize: 16, color: Colors.pink)), | |
| color: Colors.pink, | |
| ), | |
| ), | |
| // Example 10: Stopped (speed=0) | |
| _buildExample( | |
| 'Stopped (speed=0)', | |
| const DotsLoadingIndicator( | |
| speed: 0.0, | |
| text: TextSpan(text: 'Paused', style: TextStyle(fontSize: 16, color: Colors.grey)), | |
| color: Colors.grey, | |
| ), | |
| ), | |
| // Example 11: In a container with constraints | |
| _buildExample( | |
| 'In Constrained Container', | |
| Container( | |
| width: 200, | |
| padding: const EdgeInsets.all(12), | |
| decoration: BoxDecoration( | |
| color: Colors.blue.shade50, | |
| borderRadius: BorderRadius.circular(8), | |
| border: Border.all(color: Colors.blue.shade200), | |
| ), | |
| child: DotsLoadingIndicator( | |
| speed: _speed, | |
| curve: _curve, | |
| text: const TextSpan( | |
| text: 'Fetching data from server', | |
| style: TextStyle(fontSize: 14, color: Colors.blue), | |
| ), | |
| color: Colors.blue, | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| Widget _buildCurveChip(String label, Curve curve) { | |
| final isSelected = _curve == curve; | |
| return ChoiceChip( | |
| label: Text(label), | |
| selected: isSelected, | |
| onSelected: (selected) { | |
| if (selected) setState(() => _curve = curve); | |
| }, | |
| ); | |
| } | |
| Widget _buildExample(String title, Widget child) { | |
| return Padding( | |
| padding: const EdgeInsets.only(bottom: 24.0), | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.start, | |
| children: [ | |
| Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black54)), | |
| const SizedBox(height: 8), | |
| Container( | |
| width: double.infinity, | |
| padding: const EdgeInsets.all(16), | |
| decoration: BoxDecoration( | |
| color: Colors.grey.shade100, | |
| borderRadius: BorderRadius.circular(8), | |
| border: Border.all(color: Colors.grey.shade300), | |
| ), | |
| child: child, | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment