Last active
February 28, 2026 07:14
-
-
Save EsinShadrach/a5e87f54c8382c55bf902df3770d812b to your computer and use it in GitHub Desktop.
a flame icon converted to custom painter for the purpose of animation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| class AnimatedFireIcon extends StatefulWidget { | |
| final double size; | |
| final Color color; | |
| const AnimatedFireIcon({ | |
| super.key, | |
| this.size = 24.0, | |
| this.color = Colors.white, | |
| }); | |
| @override | |
| State<AnimatedFireIcon> createState() => _AnimatedFireIconState(); | |
| } | |
| class _AnimatedFireIconState extends State<AnimatedFireIcon> | |
| with SingleTickerProviderStateMixin { | |
| late final AnimationController _controller; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _controller = AnimationController( | |
| vsync: this, | |
| duration: const Duration(milliseconds: 1500), | |
| )..repeat(); | |
| } | |
| @override | |
| void dispose() { | |
| _controller.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return AnimatedBuilder( | |
| animation: _controller, | |
| builder: (context, child) { | |
| return CustomPaint( | |
| size: Size(widget.size, widget.size), | |
| painter: FirePainter( | |
| animationValue: _controller.value, | |
| color: widget.color, | |
| ), | |
| ); | |
| }, | |
| ); | |
| } | |
| } | |
| class FirePainter extends CustomPainter { | |
| final double animationValue; | |
| final Color color; | |
| FirePainter({required this.animationValue, required this.color}); | |
| Offset _distort(double x, double y, double t, double phase) { | |
| // Flame base is around y=28, tip is around y=3 | |
| // Normalized height: 0.0 at the base, 1.0 at the highest tip | |
| double normalizedY = math.max(0.0, (28.0 - y) / 25.0); | |
| // Rigidity: The base should barely move, and movement scales up towards the tip | |
| // Lowered the power slightly so the sides don't look as rigidly broken from the tip | |
| double swayFactor = math.pow(normalizedY, 2.0).toDouble(); | |
| // The core of the fluid animation: an upward-traveling wave. | |
| // To ensure a PERFECT loop when t goes from 1.0 back to 0.0, | |
| // the multiplier on 't' MUST be an integer multiple of pi * 2. | |
| // (t * math.pi * 2) loops perfectly. | |
| // The multiplier on normalizedY (here math.pi * 1.5) determines how many "waves" | |
| // fit onto the flame vertically. We keep this constant so it just offsets the phase based on height. | |
| double wavePhase = t * math.pi * 2 + phase - (normalizedY * math.pi * 1.5); | |
| // Swirl horizontally with the traveling wave. | |
| // Reduced amplitude from 3.0 to 2.0 to make the sides less rough/jagged. | |
| double swayX = math.sin(wavePhase) * 2.0 * swayFactor; | |
| // Stretch vertically to give a flickering/licking effect. | |
| // To ensure perfect looping, the time multiplier here must also be an integer multiple of pi * 2. | |
| // Previously it was (wavePhase * 1.5) which broke the t=1.0 loop boundary. | |
| // Now we use (t * math.pi * 4), which loops twice per cycle perfectly. | |
| double stretchPhase = | |
| t * math.pi * 4 + phase - (normalizedY * math.pi * 2.0); | |
| double stretchY = math.cos(stretchPhase) * 1.5 * swayFactor; | |
| // Add a gentle "breathing" scale to the entire flame, anchored near the bottom-center (15, 26) | |
| double breathe = | |
| 1.0 + | |
| math.sin(t * math.pi * 2 + phase) * 0.02; // Reduced breathing slightly | |
| double px = 15.0 + (x - 15.0) * breathe; | |
| double py = 26.0 + (y - 26.0) * breathe; | |
| return Offset(px + swayX, py + stretchY); | |
| } | |
| Path _getDistortedOuter(double t, double phase) { | |
| final p = Path(); | |
| Offset d(double x, double y) => _distort(x, y, t, phase); | |
| var pt = d(16.04, 27.2507); | |
| p.moveTo(pt.dx, pt.dy); | |
| var c1 = d(19.9475, 26.4682); | |
| var c2 = d(25, 23.657); | |
| var p1 = d(25, 16.3882); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(25, 9.77449); | |
| c2 = d(20.1588, 5.36949); | |
| p1 = d(16.6775, 3.34574); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(15.9038, 2.89574); | |
| c2 = d(15, 3.48699); | |
| p1 = d(15, 4.38074); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| pt = d(15, 6.66574); | |
| p.lineTo(pt.dx, pt.dy); | |
| c1 = d(15, 8.46824); | |
| c2 = d(14.2425, 11.7582); | |
| p1 = d(12.1375, 13.127); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(11.0625, 13.8257); | |
| c2 = d(9.9, 12.7795); | |
| p1 = d(9.77, 11.5045); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| pt = d(9.6625, 10.457); | |
| p.lineTo(pt.dx, pt.dy); | |
| c1 = d(9.5375, 9.23949); | |
| c2 = d(8.2975, 8.50074); | |
| p1 = d(7.325, 9.24324); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(5.57625, 10.5745); | |
| c2 = d(3.75, 12.912); | |
| p1 = d(3.75, 16.387); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(3.75, 25.2757); | |
| c2 = d(10.3612, 27.4995); | |
| p1 = d(13.6662, 27.4995); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(13.8596, 27.4995); | |
| c2 = d(14.0612, 27.4932); | |
| p1 = d(14.2712, 27.4807); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(14.8287, 27.4107); | |
| c2 = d(14.2712, 27.6045); | |
| p1 = d(16.04, 27.2495); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| return p; | |
| } | |
| Path _getDistortedInner(double t, double phase) { | |
| final p = Path(); | |
| // Inner flame needs to be larger. We can dynamically scale up its coordinates relative to the base anchor. | |
| // The inner flame sits roughly between x=10 and x=18, y=17 to y=27 | |
| Offset d(double x, double y) { | |
| double scale = 1.15; // Reduced from 1.35 to 1.15 | |
| double cx = 14.5; // Roughly horizontal center of inner flame | |
| double cy = 25.0; // Roughly vertical anchor of inner flame | |
| double scaledX = cx + (x - cx) * scale; | |
| // Subtracting 1.5 moves the entire inner flame upwards slightly | |
| double scaledY = cy + (y - cy) * scale - 1.5; | |
| // Apply the same global distortion to these newly scaled coordinates | |
| return _distort(scaledX, scaledY, t, phase); | |
| } | |
| var pt = d(10, 23.0548); | |
| p.moveTo(pt.dx, pt.dy); | |
| var c1 = d(10, 26.3298); | |
| var c2 = d(12.6387, 27.3423); | |
| var p1 = d(14.2712, 27.4823); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(14.8287, 27.4123); | |
| c2 = d(14.2712, 27.606); | |
| p1 = d(16.04, 27.251); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(17.3387, 26.7923); | |
| c2 = d(18.75, 25.6148); | |
| p1 = d(18.75, 23.0548); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(18.75, 21.4335); | |
| c2 = d(17.7262, 20.4323); | |
| p1 = d(16.925, 19.9635); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(16.68, 19.8198); | |
| c2 = d(16.395, 20.001); | |
| p1 = d(16.3737, 20.2835); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(16.3038, 21.181); | |
| c2 = d(15.4412, 21.896); | |
| p1 = d(14.855, 21.2135); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(14.3362, 20.611); | |
| c2 = d(14.1175, 19.7298); | |
| p1 = d(14.1175, 19.166); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| pt = d(14.1175, 18.4285); | |
| p.lineTo(pt.dx, pt.dy); | |
| c1 = d(14.1175, 17.986); | |
| c2 = d(13.6712, 17.691); | |
| p1 = d(13.2887, 17.9185); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| c1 = d(11.8687, 18.7598); | |
| c2 = d(10, 20.4923); | |
| p1 = d(10, 23.0548); | |
| p.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, p1.dx, p1.dy); | |
| p.close(); | |
| return p; | |
| } | |
| @override | |
| void paint(Canvas canvas, Size size) { | |
| final scaleX = size.width / 30.0; | |
| final scaleYVal = size.height / 30.0; | |
| final scale = math.min(scaleX, scaleYVal); | |
| canvas.save(); | |
| canvas.scale(scale, scale); | |
| final outerPaint = Paint() | |
| ..color = color.withValues(alpha: 0.5) | |
| ..style = PaintingStyle.fill; | |
| final innerPaint = Paint() | |
| ..color = color | |
| ..style = PaintingStyle.fill; | |
| // Outer flame | |
| Path outer = _getDistortedOuter(animationValue, 0.0); | |
| canvas.drawPath(outer, outerPaint); | |
| // Inner flame (slightly distinct phase to look like it's burning inside) | |
| Path inner = _getDistortedInner(animationValue, 0.4); | |
| canvas.drawPath(inner, innerPaint); | |
| // Sparks relative to 30x30 coordinate system | |
| final sparkPaint = Paint()..style = PaintingStyle.fill; | |
| for (int i = 0; i < 3; i++) { | |
| double sparkT = (animationValue + (i / 3.0)) % 1.0; | |
| double sparkY = | |
| 28.0 - (sparkT * 35.0); // go up over time beyond the 30 bounding box | |
| double sparkX = 15.0 + math.sin(sparkT * math.pi * 4 + i * 2) * 5.0; | |
| double sparkRadius = (1.0 - sparkT) * 1.5; | |
| if (sparkRadius > 0.0) { | |
| sparkPaint.color = color.withValues( | |
| alpha: math.max(0.0, 1.0 - (sparkT * 1.5)), | |
| ); | |
| canvas.drawCircle(Offset(sparkX, sparkY), sparkRadius, sparkPaint); | |
| } | |
| } | |
| canvas.restore(); | |
| } | |
| @override | |
| bool shouldRepaint(covariant FirePainter oldDelegate) { | |
| return oldDelegate.animationValue != animationValue || | |
| oldDelegate.color != color; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment