Small details that build taste in Flutter.

curated by Kamran BekirovKamran Bekirov

A neutral loading indicator

CircularProgressIndicator is a Material widget. If your design is a custome one, the spinner is one of the last places Material leaks in.

Meet Spinner, a neutral loading spinner ready for copy-paste:

import 'dart:math';
import 'package:flutter/widgets.dart';
  
const Color _defaultColor = Color(0xFF1A1A1A);
  
class Spinner extends StatefulWidget {
  final double size;
  final double strokeWidth;
  final Color? color;
  
  const Spinner({
    super.key,
    this.size = 20,
    this.strokeWidth = 2,
    this.color,
  });
  
  @override
  State<Spinner> createState() => SpinnerState();
}
  
class SpinnerState extends State<Spinner>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(
        milliseconds: 1000,
      ),
    )..repeat();
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return Semantics(
      label: 'Loading',
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Transform.rotate(
            angle: _controller.value * 2 * pi,
            child: CustomPaint(
              painter: _SpinnerPainter(
                color: widget.color ?? _defaultColor,
                strokeWidth: widget.strokeWidth,
              ),
              size: Size.square(
                widget.size,
              ),
            ),
          );
        },
      ),
    );
  }
}
  
class _SpinnerPainter extends CustomPainter {
  final Color color;
  final double strokeWidth;
  
  _SpinnerPainter({
    required this.color,
    required this.strokeWidth,
  });
  
  @override
  void paint(Canvas canvas, Size size) {
    final Paint paint = Paint()
      ..color = color
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;
  
    final double inset = strokeWidth / 2;
  
    canvas.drawArc(
      Rect.fromLTWH(
        inset,
        inset,
        size.width - strokeWidth,
        size.height - strokeWidth,
      ),
      0,
      pi * 1.5,
      false,
      paint,
    );
  }
  
  @override
  bool shouldRepaint(_SpinnerPainter oldDelegate) =>
      oldDelegate.color != color || oldDelegate.strokeWidth != strokeWidth;
}

Use it anywhere:

const Spinner()
const Spinner(
  size: 24, strokeWidth: 3,
)
const Spinner(
  color: Color(0xFFFFFFFF),
)

The arc insets by half the stroke width, so it stays inside size at any stroke. Color falls back to _defaultColor when you don't pass one.

Kamran Bekirov
Kamran Bekirov

Want this level of care in your Flutter apps?

Work With Me