
Our designer sent me a folder of animated SVGs. A spinner, a progress bar, a
few icons. Nice stuff, all of it animated right there in the file with plain
CSS and SMIL.
I dropped them into flutter_svg and got static pictures.
I spent an evening convinced I'd broken something. Wrong widget, wrong asset
path, something dumb. Turns out no: flutter_svg just skips <animate>. And it
ignores <style> too, which is worse, because that means a stylesheet-driven
SVG doesn't even render with the right colors, let alone move.
The usual answer is "convert it to Lottie". I didn't want to go back to the
designer and ask him to rebuild everything in After Effects when the animation
was already sitting in the file he'd sent me. So I wrote svg_animate.
the idea is embarrassingly simple

flutter_svg draws SVGs by compiling them into a binary format with
vector_graphics_compiler, then replaying that. That compiler is good. I had no
interest in writing a renderer.
So: don't. Take the animated document, work out what every animated attribute
is at time t, write those values back into the markup, and hand the result to
the existing compiler. Do that once per frame ahead of time, then flip through
the compiled frames like a flipbook.
A frame costs exactly what a static SVG costs to draw, because it is a static
SVG. You pay at load instead.
AnimatedSvgPicture.asset('assets/spinner.svg', width: 48)
Same arguments as SvgPicture. Reuses flutter_svg's SvgTheme and
ColorMapper so nothing in an existing app breaks. If the file has no animation
in it, it renders like a normal SvgPicture and doesn't even start a ticker.
Two evenings later the spinner was spinning. I felt great about myself for
roughly a week.
then someone gave me a real file

A 450×450 promo banner exported from SVGator. Coins, sparkles, a train, a
"BUY BONUS" badge. 90 KB of markup with five WebP images base64'd into it.
It rendered as nothing at all. Not "wrong" — a blank box.
Here's where I want to be honest about how I debugged it, because I got it wrong
first. I built a minimal test case: a tiny SVG with one <image> in it,
referenced through <use>. Nothing drew. I tried the same file through plain
flutter_svg. Nothing drew there either. Great, I thought, images just don't work
on this pipeline, not my problem.
That conclusion was garbage. I'd hand-rolled the test PNG as a base64 string and
it was truncated — zlib couldn't even decompress it. I'd proven nothing except
that a broken PNG doesn't render.
Generated a real one. Ran it again. Images render fine. <use> pointing at an
image renders too, just at the wrong size.
So back to the real file, this time properly: feed the raw SVG straight into
vector_graphics_compiler, no code of mine involved.
Failed assertion: 'width > 0 && height > 0' (writeDrawImage)
It dies in the compiler. Before any animation logic. SvgPicture would have
shown the same blank box.
Bisecting the file by deleting things: strip the 19 <use href="#img">
references and it compiles. The compiler loses an image's declared width and
height when the image is reached through a reference, gets zero, and then
refuses to draw something of zero size — killing the whole picture rather than
that one element.
Fix on my side: expand a <use> that points at an <image> into the image
itself before compiling. Only images — a <use> on a shape is fine, and
copying those would just bloat the document.
The banner rendered. Coins and all.
and then it was slow

Every compiled frame carries the images inside it, because each frame is a
self-contained picture. 82% of each 96 KB frame was image payload. At the
default 60 fps that's 300 frames and 27.5 MB.
Worse than the memory: changing frame cost 6.8 ms. The frame budget at 60fps
is 16.7 ms. That's 40% of it, just to decode, on an M-series Mac. On a mid-range
phone it would have blown straight through.
Two fixes, and I'm not proud of the first one.
The image cache. I went reading how vector_graphics decodes images and found
this:
final listener = FlutterVectorGraphicsListener(id: loader.hashCode, ...);
// ...
int _createImageKey(int imageId, int format) => Object.hash(_id, imageId, format);
It keys Flutter's global image cache by the hash code of the loader you hand it.
And mine was:
int get hashCode => Object.hash(identityHashCode(frames), frameIndex);
frameIndex in there. Every single frame opened its own namespace in the cache
and re-decoded the same five bitmaps. I had disabled a cache that was sitting
right there, with my own line of code, and then measured the result and blamed
the architecture.
Dropped frameIndex. Frames are still unequal to each other — that's what ==
is for — and two unequal objects sharing a hash code is perfectly legal Dart.
Here it's the whole point. 6.8 ms → 1.4 ms.
The duplicated bytes. For memory I nearly went and wrote a parser for the
binary format to find and hoist the image section. Then I realised I didn't need
to understand the format at all: consecutive frames of one animation are the
same document with a few numbers changed, and the encoder puts everything the
picture embeds at the front. So just compare the frames byte by byte and find
the longest run they all start with.
On the banner that run is 78,772 bytes. The compiler reports 78,538 bytes of
images. It found them, without knowing what it was looking at.
Store that once, keep only the tails, glue a frame back together when it's
asked for. 27.5 MB → 5.1 MB, and reassembling costs about 0.1 ms.
I like this one because it doesn't depend on the encoder's layout. If the format
ever stops front-loading resources, the shared run just gets shorter and nothing
breaks.
the spinner that wouldn't spin

Last one, and it's the most embarrassing, because it's the most common CSS
animation on earth:
@keyframes spin { to { transform: rotate(360deg) } }
No 0%. My code filled the missing end from the element's own transform
attribute, and when the element didn't have one, it fell back to... the other
keyframe. So both ends said 360 and the thing sat perfectly still while
everything around it animated.
The missing end has to come from the value the property has outside the
animation — no rotation for a transform, fully opaque for an opacity. With one
wrinkle: you can't substitute an empty transform, because two transform lists
only interpolate if they're built the same way. The stand-in has to keep the
shape: translate(0 0) against a translate, scale(1) against a scale.
what it doesn't do
Filters. At all. vector_graphics drops <filter> entirely, so drop shadows
and glows just don't happen — the shapes draw, the effect doesn't. Same for
mix-blend-mode: plus-lighter, which is exactly what editors reach for to make
something glow.
Also no d morphing, no <script>, no :hover, no event-based begin. If you
need any of that, full_svg_flutter
covers a lot more of the format. It's a good package. It also bundles a
JavaScript runtime, which is a fair price for what it does and not one I wanted
to pay for a spinner.
Mine adds two pure-Dart packages, both already in flutter_svg's dependency tree.
it's on pub
SMIL, CSS @keyframes, CSS motion paths (which is how SVGator writes every
movement, so its exports work), play/pause/seek through a controller.
If you've got an SVG that renders wrong, send it. Every single thing in this
post came from someone's file not working.
Top comments (0)