image

13 Jan 2026

9K

35K

Building a Progress Circle Widget for App Levels in Flutter

Progress circles are a common and visually appealing way to display progress in user interfaces. They are particularly effective in apps that feature levels, experience points, or goal completion, providing instant feedback and a sense of accomplishment. In Flutter, building a custom progress circle widget allows for complete control over its appearance and behavior. This article will guide you through creating a reusable Progress Circle widget, ideal for visualizing app level progression.

Understanding the Core Components

To create our animated progress circle, we'll primarily rely on three Flutter concepts:

  • StatefulWidget: For managing the internal state of the progress (e.g., animation controller, current progress value).
  • CustomPaint: This widget allows us to draw custom graphics directly onto the canvas. It takes a CustomPainter class as an argument.
  • CustomPainter: A class that extends CustomPainter and implements the paint and shouldRepaint methods. This is where we define how our circle and progress arc are drawn.
  • AnimationController and Tween: To create smooth transitions when the progress value changes.

Step 1: Setting Up the Widget Structure

First, let's create a basic StatefulWidget for our progress circle. This widget will hold the properties like size, colors, stroke width, and the current progress value.


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

class LevelProgressCircle extends StatefulWidget {
  final double progress; // Value between 0.0 and 1.0
  final double size;
  final double strokeWidth;
  final Color backgroundColor;
  final Color progressColor;
  final String? centerText;
  final TextStyle? centerTextStyle;

  const LevelProgressCircle({
    Key? key,
    required this.progress,
    this.size = 100.0,
    this.strokeWidth = 10.0,
    this.backgroundColor = Colors.grey,
    this.progressColor = Colors.blue,
    this.centerText,
    this.centerTextStyle,
  }) : assert(progress >= 0.0 && progress <= 1.0),
       super(key: key);

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

class _LevelProgressCircleState extends State with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation _animation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 700),
    );

    _animation = Tween(begin: 0.0, end: widget.progress).animate(
      CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
    )..addListener(() {
        setState(() {}); // Rebuild when animation value changes
      });

    _animationController.forward();
  }

  @override
  void didUpdateWidget(covariant LevelProgressCircle oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.progress != oldWidget.progress) {
      _animation = Tween(begin: oldWidget.progress, end: widget.progress).animate(
        CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
      );
      _animationController
        ..reset()
        ..forward();
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: widget.size,
      height: widget.size,
      child: Stack(
        alignment: Alignment.center,
        children: [
          CustomPaint(
            painter: _CircleProgressPainter(
              progress: _animation.value,
              backgroundColor: widget.backgroundColor,
              progressColor: widget.progressColor,
              strokeWidth: widget.strokeWidth,
            ),
            size: Size.infinite,
          ),
          if (widget.centerText != null)
            Text(
              widget.centerText!,
              style: widget.centerTextStyle ??
                  TextStyle(
                    fontSize: widget.size * 0.2,
                    fontWeight: FontWeight.bold,
                    color: Colors.black87,
                  ),
              textAlign: TextAlign.center,
            ),
        ],
      ),
    );
  }
}

In this initial setup:

  • The LevelProgressCircle widget defines various customization options.
  • _LevelProgressCircleState manages the AnimationController and Animation for smooth progress updates.
  • initState initializes the animation with the initial progress.
  • didUpdateWidget is crucial for animating changes when the progress property updates from outside the widget.
  • dispose ensures that the animation controller is released when the widget is removed from the tree.
  • The build method uses a SizedBox to define the size, a Stack to overlay text, and CustomPaint to draw the circles.

Step 2: Implementing the CustomPainter

Next, we create the _CircleProgressPainter class. This class is responsible for drawing the background circle and the progress arc based on the current progress value.


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

  _CircleProgressPainter({
    required this.progress,
    required this.backgroundColor,
    required this.progressColor,
    required this.strokeWidth,
  });

  @override
  void paint(Canvas canvas, Size size) {
    // Calculate the center of the canvas
    final center = Offset(size.width / 2, size.height / 2);
    // Calculate the radius, subtracting half of the stroke width to keep the circle within bounds
    final radius = math.min(size.width / 2, size.height / 2) - strokeWidth / 2;

    // Paint for the background circle
    final backgroundPaint = Paint()
      ..color = backgroundColor
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke;

    // Paint for the progress arc
    final progressPaint = Paint()
      ..color = progressColor
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round; // Rounded cap for a nice finish

    // Draw the background circle
    canvas.drawCircle(center, radius, backgroundPaint);

    // Define the bounding rectangle for the arc
    final Rect arcRect = Rect.fromCircle(center: center, radius: radius);

    // Calculate the sweep angle (2 * PI for a full circle)
    // Start from the top (-math.pi / 2)
    final double sweepAngle = 2 * math.pi * progress;

    // Draw the progress arc
    canvas.drawArc(
      arcRect,
      -math.pi / 2, // Start angle (top of the circle)
      sweepAngle,  // Sweep angle
      false,       // UseCenter (false for arc, true for sector)
      progressPaint,
    );
  }

  @override
  bool shouldRepaint(covariant _CircleProgressPainter oldDelegate) {
    // Repaint only if the progress, colors, or strokeWidth change
    return oldDelegate.progress != progress ||
           oldDelegate.backgroundColor != backgroundColor ||
           oldDelegate.progressColor != progressColor ||
           oldDelegate.strokeWidth != strokeWidth;
  }
}

Key points of the _CircleProgressPainter:

  • The paint method receives the Canvas and Size of the widget.
  • We calculate the center and radius to draw our circles correctly.
  • Two Paint objects are created: one for the background circle and one for the progress arc. The progress arc uses StrokeCap.round for a softer appearance at its end.
  • canvas.drawCircle draws the full background circle.
  • canvas.drawArc draws the progress. The startAngle of -math.pi / 2 makes the progress start from the top. The sweepAngle is proportional to the progress value.
  • The shouldRepaint method is optimized to only trigger a repaint when necessary, improving performance.

Step 3: Using the LevelProgressCircle Widget

Now that our widget is complete, you can easily integrate it into any part of your Flutter application. Here's an example of how to use it, perhaps within a dashboard or a user profile screen:


import 'package:flutter/material.dart';
import 'package:your_app_name/widgets/level_progress_circle.dart'; // Adjust path as needed

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

  @override
  State createState() => _HomeScreenState();
}

class _HomeScreenState extends State {
  double _currentLevelProgress = 0.3; // Example progress
  int _currentLevel = 3;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Level Progress Example'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            LevelProgressCircle(
              size: 150.0,
              strokeWidth: 15.0,
              progress: _currentLevelProgress,
              backgroundColor: Colors.grey.shade300,
              progressColor: Colors.deepPurple,
              centerText: 'Lv.\n$_currentLevel',
              centerTextStyle: const TextStyle(
                fontSize: 30,
                fontWeight: FontWeight.bold,
                color: Colors.deepPurple,
              ),
            ),
            const SizedBox(height: 40),
            Slider(
              value: _currentLevelProgress,
              min: 0.0,
              max: 1.0,
              divisions: 100,
              label: (_currentLevelProgress * 100).toStringAsFixed(0) + '%',
              onChanged: (newValue) {
                setState(() {
                  _currentLevelProgress = newValue;
                  // Optionally update level based on progress, e.g.,
                  // _currentLevel = (newValue * 10).toInt() + 1;
                });
              },
            ),
            const SizedBox(height: 20),
            Text(
              'Progress: ${(_currentLevelProgress * 100).toStringAsFixed(0)}%',
              style: const TextStyle(fontSize: 18),
            ),
          ],
        ),
      ),
    );
  }
}

In this example, we use a Slider to dynamically change the _currentLevelProgress, demonstrating how the progress circle animates smoothly to new values. The centerText is used to display the current level, making the widget highly informative.

Conclusion

By leveraging CustomPaint and AnimationController, you can create highly customizable and visually engaging progress circle widgets in Flutter. This component is excellent for enhancing user experience in apps that involve progression systems, gamification, or any scenario where a clear visual representation of completion is beneficial. Feel free to expand upon this design by adding more customization options, such as gradients for the progress color, different stroke caps, or more complex text layouts within the circle.

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