Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active October 15, 2025 05:33
Show Gist options
  • Save PlugFox/e486dcaf99d958973a1f1b1cddea789b to your computer and use it in GitHub Desktop.
Save PlugFox/e486dcaf99d958973a1f1b1cddea789b to your computer and use it in GitHub Desktop.
Dots Loading Indicator
/*
* 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