image

05 Jan 2026

9K

35K

Creating Smooth Circular Progress Animations in Flutter with Tween

Circular progress indicators are a common UI element used to visually represent the progress of a task, such as downloading a file, loading content, or completing a form. While Flutter provides a default CircularProgressIndicator, developers often need custom designs and more granular control over the animation. This article will guide you through creating a custom, animated circular progress indicator using Flutter's CustomPaint, AnimationController, and specifically, the powerful Tween class for smooth and controlled value interpolation.

Understanding the Core Components

To build our custom animated circular progress, we'll leverage several key Flutter features:

  • CustomPaint: This widget allows us to draw custom graphics directly onto the screen using a CustomPainter. We'll use it to draw the background circle and the progress arc.
  • AnimationController: Manages the animation's lifecycle, including starting, stopping, and defining its duration. It produces values that change over time.
  • Tween: Short for "in-betweening," a Tween defines a range of values (e.g., from 0.0 to 1.0) and how an AnimationController's raw animation value should be mapped to that range. This is crucial for smoothly interpolating between two values over the animation's duration. For our progress indicator, a Tween<double> will map the animation's time to a progress percentage.
  • AnimatedBuilder: This widget listens to an Animation and rebuilds its child subtree whenever the animation's value changes. It's an efficient way to update the UI without rebuilding the entire widget tree.

Step-by-Step Implementation

Let's break down the creation of our custom circular progress animation.

1. Define the Custom Painter (CircularProgressPainter)

First, we need a CustomPainter to draw the static and dynamic parts of our progress indicator. This painter will take a progress value (a double between 0.0 and 1.0) and draw an arc corresponding to that progress.


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

class CircularProgressPainter extends CustomPainter {
  final double progress;
  final Color backgroundColor;
  final Color progressColor;
  final double strokeWidth;

  CircularProgressPainter({
    required this.progress,
    this.backgroundColor = Colors.grey,
    this.progressColor = Colors.blue,
    this.strokeWidth = 10.0,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = math.min(size.width / 2, size.height / 2) - strokeWidth / 2;

    // Background circle
    final Paint backgroundPaint = Paint()
      ..color = backgroundColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth;
    canvas.drawCircle(center, radius, backgroundPaint);

    // Progress arc
    final Paint progressPaint = Paint()
      ..color = progressColor
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round // Make the ends round
      ..strokeWidth = strokeWidth;

    // Start angle (top) and sweep angle based on progress
    final double startAngle = -math.pi / 2; // -90 degrees (top)
    final double sweepAngle = 2 * math.pi * progress; // 360 degrees * progress

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle,
      sweepAngle,
      false, // Do not connect to center
      progressPaint,
    );
  }

  @override
  bool shouldRepaint(covariant CircularProgressPainter oldDelegate) {
    return oldDelegate.progress != progress ||
           oldDelegate.backgroundColor != backgroundColor ||
           oldDelegate.progressColor != progressColor ||
           oldDelegate.strokeWidth != strokeWidth;
  }
}

2. Create the Animated Widget (CircularProgressAnimation)

This widget will manage the AnimationController and Tween, and use an AnimatedBuilder to update the CustomPaint.


import 'package:flutter/material.dart';
// import 'circular_progress_painter.dart'; // Make sure to import your painter

class CircularProgressAnimation extends StatefulWidget {
  final double initialProgress; // The progress to animate to
  final Duration duration;
  final Color backgroundColor;
  final Color progressColor;
  final double strokeWidth;
  final double size; // Diameter of the circle

  const CircularProgressAnimation({
    Key? key,
    this.initialProgress = 0.0,
    this.duration = const Duration(seconds: 2),
    this.backgroundColor = Colors.grey,
    this.progressColor = Colors.blue,
    this.strokeWidth = 10.0,
    this.size = 100.0,
  }) : assert(initialProgress >= 0.0 && initialProgress <= 1.0,
            'initialProgress must be between 0.0 and 1.0'),
       super(key: key);

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

class _CircularProgressAnimationState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation; // This will hold the tweened value

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      vsync: this,
      duration: widget.duration,
    );

    // Define the Tween: maps the controller's 0.0-1.0 to our desired progress range
    // In this case, we animate from 0.0 to widget.initialProgress
    _animation = Tween<double>(begin: 0.0, end: widget.initialProgress).animate(_controller);

    // Start the animation
    _controller.forward();
  }

  @override
  void didUpdateWidget(covariant CircularProgressAnimation oldWidget) {
    super.didUpdateWidget(oldWidget);
    // If the target progress changes, restart the animation
    if (oldWidget.initialProgress != widget.initialProgress) {
      _controller.duration = widget.duration; // Ensure duration is updated if it changes
      _animation = Tween<double>(begin: oldWidget.initialProgress, end: widget.initialProgress).animate(_controller);
      _controller.forward(from: 0.0); // Restart from the beginning
    }
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: widget.size,
      height: widget.size,
      child: AnimatedBuilder(
        animation: _animation, // Listen to our tweened animation
        builder: (context, child) {
          return CustomPaint(
            painter: CircularProgressPainter(
              progress: _animation.value, // Use the current tweened value
              backgroundColor: widget.backgroundColor,
              progressColor: widget.progressColor,
              strokeWidth: widget.strokeWidth,
            ),
          );
        },
      ),
    );
  }

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

Explanation of Key Parts:

  • _controller = AnimationController(...): Initializes an animation controller that will run for the specified duration. vsync: this prevents animations from consuming unnecessary resources when the off-screen.
  • _animation = Tween<double>(begin: 0.0, end: widget.initialProgress).animate(_controller);: This is where Tween comes into play.
    • Tween<double>(begin: 0.0, end: widget.initialProgress) creates a Tween that will interpolate double values from 0.0 to the target initialProgress.
    • .animate(_controller) connects this Tween to our AnimationController. The Tween takes the _controller's raw value (which goes from 0.0 to 1.0 over its duration) and maps it to our defined range (0.0 to widget.initialProgress). This results in an Animation<double> that produces values suitable for our progress display.
  • _controller.forward();: Starts the animation, making the controller's value increase from 0.0 to 1.0, which in turn drives our _animation from 0.0 to widget.initialProgress.
  • didUpdateWidget: This method is crucial for making the animation responsive to changes in the target progress. When initialProgress changes, we create a new Tween starting from the old progress value and animating to the new one, then restart the controller.
  • AnimatedBuilder(animation: _animation, builder: ...): This widget listens to changes in _animation. Whenever _animation.value changes, the builder function is called, rebuilding only the CustomPaint widget with the new progress value. This is highly performant.

3. Example Usage

You can use this animated circular progress indicator in any Flutter widget, for instance, inside a Scaffold:


import 'package:flutter/material.dart';
// import 'circular_progress_animation.dart'; // Make sure to import your animation widget

void main() {
  runApp(const MyApp());
}

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

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  double _currentProgress = 0.2; // Initial progress value

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Custom Circular Progress Animation'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              CircularProgressAnimation(
                initialProgress: _currentProgress,
                duration: const Duration(seconds: 2),
                size: 150.0,
                strokeWidth: 15.0,
                progressColor: Colors.purple,
                backgroundColor: Colors.purple.shade100,
              ),
              const SizedBox(height: 30),
              ElevatedButton(
                onPressed: () {
                  setState(() {
                    // Update progress, simulating a task
                    _currentProgress = (_currentProgress + 0.2) % 1.0;
                    if (_currentProgress == 0.0) _currentProgress = 1.0; // Avoid animating to 0 from 0
                  });
                },
                child: const Text('Update Progress'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Conclusion

By combining CustomPaint for drawing, AnimationController for timing, and critically, Tween for value interpolation, we've successfully created a highly customizable and smoothly animated circular progress indicator in Flutter. The Tween class allows us to precisely define the range of values our animation should produce, decoupling it from the raw 0.0-1.0 output of the controller and enabling diverse animation effects. This pattern is fundamental for building complex and engaging custom animations in your Flutter applications, offering full control over the visual presentation and animation behavior.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is