Implementing Custom Loading Indicators in Flutter

Loading indicators are crucial for providing feedback to users during data fetching or processing tasks in mobile applications. While Flutter offers a default CircularProgressIndicator and LinearProgressIndicator, customizing these indicators can greatly enhance the user experience by aligning with the app’s design language and branding.

Why Customize Loading Indicators?

  • Branding Consistency: Match the app’s visual style.
  • Enhanced User Experience: Make loading states more engaging.
  • Differentiate Your App: Stand out with unique animations.

Approaches to Creating Custom Loading Indicators in Flutter

Flutter provides several ways to create custom loading indicators:

  • Using existing widgets and modifying their properties.
  • Creating custom painters for intricate designs.
  • Using animated widgets and transitions.
  • Utilizing third-party libraries.

Method 1: Modifying Existing Widgets

You can customize the default CircularProgressIndicator and LinearProgressIndicator by adjusting their colors, stroke widths, and background properties.

Custom Circular Progress Indicator


import 'package:flutter/material.dart';

class CustomCircularProgressIndicator extends StatelessWidget {
  final double? value; // Nullable for indeterminate state
  final Color? color;

  const CustomCircularProgressIndicator({Key? key, this.value, this.color}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 40, // Fixed size for visibility, adjust as needed
      height: 40,
      child: CircularProgressIndicator(
        value: value,
        strokeWidth: 5,
        valueColor: AlwaysStoppedAnimation(color ?? Theme.of(context).primaryColor),
        backgroundColor: Colors.grey[200],
      ),
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Custom Circular Progress Indicator')),
        body: const Center(
          child: CustomCircularProgressIndicator(color: Colors.orange), //Example Use
        ),
      ),
    ),
  );
}

Usage example:


CustomCircularProgressIndicator(color: Colors.orange, value: 0.75),

Key modifications:

  • valueColor: Change the indicator’s color.
  • strokeWidth: Adjust the thickness of the indicator’s stroke.
  • backgroundColor: Set a background color for the track.
  • value: A nullable value represents a percentage or null for an indeterminate state

Custom Linear Progress Indicator


import 'package:flutter/material.dart';

class CustomLinearProgressIndicator extends StatelessWidget {
  final double? value;
  final Color? color;

  const CustomLinearProgressIndicator({Key? key, this.value, this.color}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 200, // or desired size
      child: LinearProgressIndicator(
        value: value,
        backgroundColor: Colors.grey[200],
        valueColor: AlwaysStoppedAnimation(color ?? Theme.of(context).primaryColor),
      ),
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Custom Linear Progress Indicator')),
        body: const Center(
          child: CustomLinearProgressIndicator(color: Colors.green, value: 0.6), // Example Use
        ),
      ),
    ),
  );
}

Usage example:


CustomLinearProgressIndicator(color: Colors.green, value: 0.6),

Method 2: Creating Custom Painters

For more intricate and unique designs, you can create custom loading indicators using Flutter’s CustomPainter. This approach allows you to draw anything you can imagine.

Example: A Pulsating Circle Loader


import 'dart:math';
import 'package:flutter/material.dart';

class PulsatingCirclePainter extends CustomPainter {
  final Animation animation;

  PulsatingCirclePainter({required this.animation}) : super(repaint: animation);

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2;
    final scale = sin(animation.value * pi); // Sine wave for pulsing effect

    final paint = Paint()
      ..color = Colors.blue.withOpacity(0.5 + 0.5 * scale)
      ..style = PaintingStyle.fill;

    canvas.drawCircle(center, radius * scale, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false; // Repaint handled by AnimationController
  }
}


class PulsatingCircleLoadingIndicator extends StatefulWidget {
  const PulsatingCircleLoadingIndicator({Key? key}) : super(key: key);

  @override
  _PulsatingCircleLoadingIndicatorState createState() => _PulsatingCircleLoadingIndicatorState();
}

class _PulsatingCircleLoadingIndicatorState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    )..repeat(); // Animate indefinitely
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: PulsatingCirclePainter(animation: _controller),
      size: const Size(50, 50), // Size of the painted area
    );
  }
}


void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Custom Pulsating Circle Loading Indicator')),
        body: const Center(
          child: PulsatingCircleLoadingIndicator(), //Example Use
        ),
      ),
    ),
  );
}

Key components:

  • PulsatingCirclePainter: Draws a circle with a radius that pulses using a sine wave.
  • AnimationController: Controls the animation, causing the circle to pulsate continuously.

Method 3: Animated Widgets and Transitions

Flutter’s built-in animated widgets such as AnimatedOpacity, AnimatedScale, and AnimatedContainer can be combined to create interesting loading indicators without needing custom painting.

Example: Fading Dots Loader


import 'package:flutter/material.dart';

class FadingDotsLoader extends StatefulWidget {
  final Color color;
  final double size;

  const FadingDotsLoader({Key? key, this.color = Colors.blue, this.size = 10.0}) : super(key: key);

  @override
  _FadingDotsLoaderState createState() => _FadingDotsLoaderState();
}

class _FadingDotsLoaderState extends State with TickerProviderStateMixin {
  late List _animationControllers;
  late List> _animations;

  @override
  void initState() {
    super.initState();
    _animationControllers = List.generate(3, (index) => AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 600),
    ));

    _animations = _animationControllers.map((controller) =>
        Tween(begin: 0.3, end: 1.0).animate(
          CurvedAnimation(parent: controller, curve: Curves.easeInOut),
        )).toList();

    for (int i = 0; i < _animationControllers.length; i++) {
      Future.delayed(Duration(milliseconds: 200 * i), () {
        _animationControllers[i].repeat(reverse: true);
      });
    }
  }

  @override
  void dispose() {
    for (var controller in _animationControllers) {
      controller.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: List.generate(3, (index) => Padding(
        padding: const EdgeInsets.symmetric(horizontal: 4.0),
        child: AnimatedOpacity(
          opacity: _animations[index].value,
          duration: const Duration(milliseconds: 600),
          child: Container(
            width: widget.size,
            height: widget.size,
            decoration: BoxDecoration(
              color: widget.color,
              shape: BoxShape.circle,
            ),
          ),
        ),
      )),
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Custom Fading Dots Loader')),
        body: const Center(
          child: FadingDotsLoader(), //Example Use
        ),
      ),
    ),
  );
}

Explanation:

  • Three dots are created, each animated to fade in and out.
  • AnimatedOpacity is used to control the opacity of each dot.
  • AnimationControllers are used to control the fading animation for each dot.

Method 4: Utilizing Third-Party Libraries

Several Flutter packages provide a wide variety of pre-built, customizable loading indicators.


dependencies:
  flutter:
    sdk: flutter
  flutter_spinkit: ^5.2.0

Example using flutter_spinkit


import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';

class ThirdPartyLoadingIndicator extends StatelessWidget {
  const ThirdPartyLoadingIndicator({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const SpinKitFadingCircle(
      color: Colors.green,
      size: 50.0,
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Third-Party Loading Indicator')),
        body: const Center(
          child: ThirdPartyLoadingIndicator(), //Example Use
        ),
      ),
    ),
  );
}

Some popular libraries:

Conclusion

Custom loading indicators are an effective way to enhance the user experience and maintain brand consistency in Flutter applications. Whether by modifying existing widgets, creating custom painters, or utilizing third-party libraries, Flutter offers ample flexibility to create unique and engaging loading animations.