Skip to content

Instantly share code, notes, and snippets.

@EsinShadrach
Last active February 28, 2026 07:14
Show Gist options
  • Select an option

  • Save EsinShadrach/a5e87f54c8382c55bf902df3770d812b to your computer and use it in GitHub Desktop.

Select an option

Save EsinShadrach/a5e87f54c8382c55bf902df3770d812b to your computer and use it in GitHub Desktop.
a flame icon converted to custom painter for the purpose of animation
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