flutterflutter-custompainterflutter-custompaint

Create spiral in flutter


I want to create a spiral in flutter which will have a dynamic fill, something like shown below. One way to create is by using multiple semi-circles, but is there a better way to achieve this?

The dynamic fill is orange in color and will depend on a percentage value.

enter image description here


Solution

  • I was able to make it from scratch using a bit of gematrical calculations. My code below:

    class SpiralPainter extends CustomPainter {
      late BuildContext context;
      int fillPercent; // to show orange fill showing the target progress
    
      SpiralPainter(this.context, this.fillPercent);
    
      @override
      void paint(Canvas canvas, Size size) {
        const double radius = 180.0; //radius of outermost spiral arc
        const double strokeWidth = 10; // width of the spiral stroke
        const double radiiDecrement = 30.0; // gap in radius between the spirals
        const double centerShift = 1 +
            (radiiDecrement + strokeWidth) /
                2; // shift in center point between immediate arcs used to create the spiral
        Offset center = Offset(
            0.5 * size.width, 0.5 * size.height); // center of the outermost spiral
    
        // paint colors and strokes for spiral
        final Paint paintSpiralTargetMet = Paint()
          ..color = Theme.of(context).colorScheme.primary
          ..strokeWidth = strokeWidth
          ..style = PaintingStyle.stroke;
        final Paint paintSpiralTargetNotMet = Paint()
          ..color = Colors.black45
          ..strokeWidth = strokeWidth
          ..style = PaintingStyle.stroke;
    
        double spiralRadius = radius;
        double userProgress =
            44; //TODO: fetch it from firebase when available, use fillPercent class variable
        double arcFillPercent = (userProgress / 25);
        //this variable is used to determine which arcs will be fully filled or fully unfilled
        int arcFillCount = arcFillPercent.ceil();
        //this variable is used to partially fill an arc with orange color indicating the running progress
        arcFillPercent =
            arcFillPercent - arcFillPercent.floor(); // get fraction part
    
        // Spiral is drawn using the 4 arcs of reducing radius and shifting center
        // so this loop runs 4 times
        for (var i = 1; i < 5; i++) {
          double sweepAngle = pi;
          double startAngle = pi / 4 + (pi * ((i - 1) % 2));
          if (i > arcFillCount) {
            //whole arc will be unfilled
            canvas.drawArc(Rect.fromCircle(center: center, radius: spiralRadius),
                startAngle, sweepAngle, false, paintSpiralTargetNotMet);
          } else if (i < arcFillCount) {
            //whole arc will be filled
            canvas.drawArc(Rect.fromCircle(center: center, radius: spiralRadius),
                startAngle, sweepAngle, false, paintSpiralTargetMet);
          } else {
            if (arcFillPercent == 0) {
              //whole arc will be filled
              canvas.drawArc(Rect.fromCircle(center: center, radius: spiralRadius),
                  startAngle, sweepAngle, false, paintSpiralTargetMet);
            } else {
              //part of arc will be filled and rest unfilled
              sweepAngle = pi * (arcFillPercent);
              startAngle += pi * (1 - arcFillPercent);
              startAngle =
                  startAngle > (2 * pi) ? (startAngle - (2 * pi)) : startAngle;
              canvas.drawArc(Rect.fromCircle(center: center, radius: spiralRadius),
                  startAngle, sweepAngle, false, paintSpiralTargetMet);
    
              startAngle = pi / 4 + (pi * ((i - 1) % 2));
              startAngle =
                  startAngle > (2 * pi) ? (startAngle - (2 * pi)) : startAngle;
              sweepAngle = pi * (1 - arcFillPercent);
              canvas.drawArc(Rect.fromCircle(center: center, radius: spiralRadius),
                  startAngle, sweepAngle, false, paintSpiralTargetNotMet);
            }
          }
          //reduce the radius and shift the center
          spiralRadius -= radiiDecrement;
          if (i % 2 == 0) {
            center -= const Offset(centerShift, centerShift);
          } else {
            center += const Offset(centerShift, centerShift);
          }
        }
        const double smallGoalRadius = 25; // radius of small intermediate targets
        //center of circle from top left corner
    
        // paint colors and strokes for circle and spiral
        final Paint paintCircleTargetMet =
            Paint() // for the circles where target is met
              ..color = Theme.of(context).colorScheme.primary;
        final Paint paintTargetNotMet =
            Paint() // for the circles where target is not met
              ..color = Colors.black;
        // do not modify the code below
        // unless you completely understand the math (geometry) behind it
        // Array containing checkpoints based on team running progress
        List<double> smallGoalCheckPoints = [0, 12.49, 24.99, 37.49, 56.34, 81.24];
        Offset smallGoalCenter =
            center - Offset(radius / sqrt(2), radius / sqrt(2));
        canvas.drawCircle(
            smallGoalCenter,
            smallGoalRadius,
            userProgress > smallGoalCheckPoints[0]
                ? paintCircleTargetMet
                : paintTargetNotMet);
        smallGoalCenter = smallGoalCenter.translate(0, 2 * radius / sqrt(2));
        canvas.drawCircle(
            smallGoalCenter,
            smallGoalRadius,
            userProgress > smallGoalCheckPoints[1]
                ? paintCircleTargetMet
                : paintTargetNotMet);
        smallGoalCenter = smallGoalCenter.translate(2 * radius / sqrt(2), 0);
        canvas.drawCircle(
            smallGoalCenter,
            smallGoalRadius,
            userProgress > smallGoalCheckPoints[2]
                ? paintCircleTargetMet
                : paintTargetNotMet);
        smallGoalCenter =
            smallGoalCenter.translate(0, -2 * (radius - radiiDecrement) / sqrt(2));
        canvas.drawCircle(
            smallGoalCenter,
            smallGoalRadius,
            userProgress > smallGoalCheckPoints[3]
                ? paintCircleTargetMet
                : paintTargetNotMet);
        smallGoalCenter = center -
            const Offset(
                radius - 2 * radiiDecrement - strokeWidth / 2, -1 * centerShift);
        canvas.drawCircle(
            smallGoalCenter,
            smallGoalRadius,
            userProgress > smallGoalCheckPoints[4]
                ? paintCircleTargetMet
                : paintTargetNotMet);
        smallGoalCenter = smallGoalCenter.translate(
            smallGoalRadius + 2 * radius - 5 * radiiDecrement - strokeWidth, 0);
        canvas.drawCircle(
            smallGoalCenter,
            smallGoalRadius,
            userProgress > smallGoalCheckPoints[5]
                ? paintCircleTargetMet
                : paintTargetNotMet);
      }
    
      @override
      bool shouldRepaint(CustomPainter oldDelegate) {
        return false;
      }
    }