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.
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;
}
}