svgx 0.1.0 copy "svgx: ^0.1.0" to clipboard
svgx: ^0.1.0 copied to clipboard

High-performance static & animated SVG rendering for Flutter. Rust (usvg) parses, Flutter's own GPU pipeline renders — no CPU rasterizer, SMIL animation support.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:svgx/svgx.dart';

// The line-md `confirm_circle` icon SVG source, verbatim. Its `<g>` element
// already uses `fill="none"` (the upstream `fill="currentColor"` bug fixed) so
// the stroke-draw circle + checkmark animation stays visible instead of being
// hidden behind a solid filled disc.
const String kConfirmCircleSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="60" stroke-dashoffset="60" d="M3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.5s" values="60;0"/></path><path stroke-dasharray="14" stroke-dashoffset="14" d="M8 12L11 15L16 10"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.6s" dur="0.2s" values="14;0"/></path></g></svg>';

// mdi:check-circle — a single filled path, no stroke, no `<animate>`. Proves
// the Rust usvg → display-list → ui.Picture static path end to end.
const String kStaticCheckCircleSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<path fill="#FF7A00" d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2m-1.94 14.5L5.7 12.11l1.41-1.42l2.95 2.96l6.24-6.25l1.41 1.42z"/>'
    '</svg>';

// svg-spinners `180-ring`, verbatim (Iconify's `svg-spinners` collection —
// icons/180-ring.svg). Proves `<animateTransform type="rotate">` with
// `repeatCount="indefinite"`: the foreground arc rotates 0deg->360deg about
// its own center (12,12) forever, layered over a static translucent ring.
const String kSpinnerSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25"/>'
    '<path fill="currentColor" d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z">'
    '<animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/>'
    '</path></svg>';

// A synthetic, unambiguous rotation proof: a small square offset to the top
// of the viewBox, orbiting the center forever via `<animateTransform
// type="rotate" repeatCount="indefinite">`. Unlike the subtle opacity
// contrast in a real spinner icon, an off-center marker makes rotation
// impossible to miss frame-to-frame.
const String kRotationMarkerSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<circle cx="12" cy="12" r="10" fill="none" stroke="#DDDDDD" stroke-width="1"/>'
    '<rect x="10" y="1" width="4" height="4" fill="#2A6DF4">'
    '<animateTransform attributeName="transform" dur="2s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/>'
    '</rect></svg>';

// A static (no `<animate>`) filled path using `currentColor` — exercises the
// Rust static path's new `currentColor` substitution via `SvgxStatic`'s
// `theme` param, resolved through `parse_svg`'s `current_color` FFI arg.
const String kStaticCurrentColorSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<path fill="currentColor" d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2m-1.94 14.5L5.7 12.11l1.41-1.42l2.95 2.96l6.24-6.25l1.41 1.42z"/>'
    '</svg>';

// A circle with a thick, semi-transparent stroke over an opaque fill.
// Rendered twice below with default paint-order (fill then stroke) vs
// `paint-order="stroke fill"` — the overlap band where stroke crosses fill
// looks visually different depending on which is painted on top.
String _paintOrderCircleSvg({required bool strokeFirst}) =>
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<circle cx="12" cy="12" r="8" fill="#2A6DF4" stroke="#FF0000" stroke-opacity="0.5" stroke-width="6"'
    '${strokeFirst ? ' paint-order="stroke fill"' : ''}/>'
    '</svg>';

// --- Feature demos added to visually verify recently-completed engine
// features (see svgx CLAUDE.md "当前动画引擎支持" / static-path notes).
// Each demo is a standalone SVG string exercising exactly one feature.

// 1. `<image>` embedded base64 PNG — a 1x1 red-pixel PNG data URI, scaled up
// to fill the viewBox. Exercises `SvgNodeKind.image` /
// `resolveImageNodes`/`_decodeDataUriImage` on the animated path.
const String kEmbeddedImageSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<image href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" '
    'x="0" y="0" width="24" height="24"/>'
    '</svg>';

// 2. Named CSS colors — `fill="tomato"` / `stroke="cornflowerblue"`.
// Exercises `_normalizeColorAttributes` -> `resolveColorToHex`.
const String kNamedColorsSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<circle cx="12" cy="12" r="9" fill="tomato" stroke="cornflowerblue" stroke-width="3"/>'
    '</svg>';

// 3. `<g transform="...">` static transform — combined translate/rotate/scale
// wrapping a square. Exercises the `transform` attribute parsed on any node
// (not just animateTransform targets) via `parseTransformMatrix`.
const String kGroupTransformSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<g transform="translate(12,12) rotate(45) scale(0.6)">'
    '<rect x="-8" y="-8" width="16" height="16" fill="#2A6DF4"/>'
    '</g></svg>';

// 4. `<animateTransform type="skewX">` — a square continuously shearing along
// X. Exercises `SmilTransformType.skewX`.
const String kSkewXSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<rect x="8" y="8" width="8" height="8" fill="#2A6DF4">'
    '<animateTransform attributeName="transform" type="skewX" dur="1.5s" '
    'values="0;30;0;-30;0" repeatCount="indefinite"/>'
    '</rect></svg>';

// 5. `<use>` referencing a `<defs>`-defined shape that itself carries an
// `<animate>` — the clone plays the same animation independently. Exercises
// `_resolveUse`'s "re-parse the target including its own animate children"
// semantics.
const String kUseAnimatedSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<defs><circle id="pulsingDot" cx="0" cy="0" r="3" fill="#2A6DF4">'
    '<animate attributeName="r" values="3;6;3" dur="1s" repeatCount="indefinite"/>'
    '</circle></defs>'
    '<use xlink:href="#pulsingDot" x="8" y="12"/>'
    '<use xlink:href="#pulsingDot" x="16" y="12"/>'
    '</svg>';

// 6. Syncbase `begin` — a "relay": the second `<animate>` only starts once the
// first (`id="firstAnim"`) ends, via `begin="firstAnim.end"`. Exercises
// `parseSmilBeginSpec`'s syncbase form + `resolveSmilBeginTimes`.
//
// Neither `<animate>` has `repeatCount="indefinite"` — a syncbase relay can't
// use it on the *first* animation, because `resolveSmilBeginTimes` treats an
// indefinitely-repeating target's `.end` as never arriving (by design: an
// indefinite loop has no end to sync to) and disables the second animation
// entirely. So the relay plays once (2s total) and freezes — see
// `_RestartingSvg` below, which remounts this widget on a timer so the demo
// page shows the relay repeating instead of freezing after the first play.
const String kSyncbaseRelaySvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<circle cx="12" cy="12" r="10" fill="none" stroke="#DDDDDD" stroke-width="1"/>'
    '<rect x="2" y="10" width="4" height="4" fill="#2A6DF4">'
    '<animate id="firstAnim" attributeName="x" from="2" to="9" dur="1s" fill="freeze"/>'
    '<animate attributeName="x" begin="firstAnim.end" from="9" to="18" dur="1s" fill="freeze"/>'
    '</rect></svg>';

// 7. `<animateMotion>` — a small circle traveling along a wavy path with
// `rotate="auto"`. Exercises `_parseAnimateMotion`/`_sampleMotionPath`.
const String kAnimateMotionSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<path d="M2,12 Q7,2 12,12 T22,12" fill="none" stroke="#DDDDDD" stroke-width="1"/>'
    '<circle r="2" fill="#FF7A00">'
    '<animateMotion path="M2,12 Q7,2 12,12 T22,12" dur="2s" rotate="auto" repeatCount="indefinite"/>'
    '</circle></svg>';

// 8. Static gradients — a rect filled with a `linearGradient` defined in
// `<defs>`. No `<animate>`, so this routes through the Rust (usvg) static
// path rather than the animated engine.
const String kLinearGradientSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<defs><linearGradient id="grad1" x1="0" y1="0" x2="1" y2="1">'
    '<stop offset="0%" stop-color="#2A6DF4"/>'
    '<stop offset="100%" stop-color="#FF7A00"/>'
    '</linearGradient></defs>'
    '<rect x="2" y="2" width="20" height="20" rx="4" fill="url(#grad1)"/>'
    '</svg>';

// 9. Animated `<mask>` (Task 1, 2026-08-25): a blue circle revealed
// left-to-right by a growing white mask rect. Exercises `SvgNode.maskId` +
// `AnimatedSvgPainter._applyMaskLayer` sampling the mask content per frame.
const String kAnimatedMaskSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<defs><mask id="reveal"><rect x="0" y="0" width="0" height="24" fill="#FFFFFF">'
    '<animate attributeName="width" from="0" to="24" dur="1.5s" repeatCount="indefinite"/>'
    '</rect></mask></defs>'
    '<circle cx="12" cy="12" r="10" fill="#2A6DF4" mask="url(#reveal)"/>'
    '</svg>';

// 10. `<text>` (Task 2, 2026-08-25): plain text content with an opacity
// fade-in, proving the generic per-frame animation mechanism applies to text
// like any other node kind.
const String kTextSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="80" height="24" viewBox="0 0 80 24">'
    '<text x="2" y="17" font-size="16" font-family="sans-serif" fill="#2A6DF4">'
    'svgx'
    '<animate attributeName="opacity" values="0.2;1;0.2" dur="2s" repeatCount="indefinite"/>'
    '</text></svg>';

// 11. `<animateMotion keyPoints/keyTimes>` (Task 3, 2026-08-25): the marker
// spends most of its time near the start of the path then rushes to the end
// — keyPoints="0;0.1;1" with even keyTimes makes the first half of the
// duration cover only 10% of the arc length.
const String kAnimateMotionKeyPointsSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<path d="M2,12 Q7,2 12,12 T22,12" fill="none" stroke="#DDDDDD" stroke-width="1"/>'
    '<circle r="2" fill="#FF7A00">'
    '<animateMotion path="M2,12 Q7,2 12,12 T22,12" keyPoints="0;0.1;1" keyTimes="0;0.5;1" '
    'dur="2s" repeatCount="indefinite"/>'
    '</circle></svg>';

// 12. Animated gradient (Task 4, 2026-08-25): the same rect as
// `kLinearGradientSvg`, but the gradient's own `x2`/`y2` animate and its first
// `<stop>` cycles colour — this SVG contains `<animate>` (on the gradient
// definition), so it routes through the animated engine rather than the
// static Rust path.
const String kAnimatedGradientSvg =
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
    '<defs><linearGradient id="animGrad" x1="0" y1="0" x2="1" y2="0">'
    '<animate attributeName="x2" values="1;0;1" dur="2s" repeatCount="indefinite"/>'
    '<animate attributeName="y2" values="0;1;0" dur="2s" repeatCount="indefinite"/>'
    '<stop offset="0%" stop-color="#2A6DF4">'
    '<animate attributeName="stop-color" values="#2A6DF4;#FF7A00;#2A6DF4" dur="2s" repeatCount="indefinite"/>'
    '</stop>'
    '<stop offset="100%" stop-color="#FF7A00"/>'
    '</linearGradient></defs>'
    '<rect x="2" y="2" width="20" height="20" rx="4" fill="url(#animGrad)"/>'
    '</svg>';

Future<void> main() async {
  await RustLib.init();
  runApp(const MyApp());
}

/// Remounts an [Svgx.string] on a timer so a non-repeating (no
/// `repeatCount="indefinite"`) demo animation replays instead of staying
/// frozen at its final frame once played.
///
/// 定时重新挂载 [Svgx.string],让不带 `repeatCount="indefinite"` 的演示动画
/// 反复重播,而不是播放一次后永远定格在终态。
class _RestartingSvg extends StatefulWidget {
  const _RestartingSvg(
    this.source, {
    required this.width,
    required this.height,
    required this.interval,
  });

  final String source;
  final double width;
  final double height;
  final Duration interval;

  @override
  State<_RestartingSvg> createState() => _RestartingSvgState();
}

class _RestartingSvgState extends State<_RestartingSvg> {
  int _generation = 0;
  late final Timer _timer;

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(
      widget.interval,
      (_) => setState(() => _generation++),
    );
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Svgx.string(
      widget.source,
      key: ValueKey(_generation),
      width: widget.width,
      height: widget.height,
    );
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('svgx acceptance test')),
        body: Center(
          child: SingleChildScrollView(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                // TEMP WSA DIAGNOSTIC PROBE — reverted before finishing.
                Container(
                  width: 300,
                  height: 80,
                  color: const Color(0xFFFFFF00),
                  alignment: Alignment.center,
                  child: const Text(
                    'plain text test',
                    style: TextStyle(fontSize: 28, color: Color(0xFFFF0000)),
                  ),
                ),
                const SizedBox(height: 24),
                const Text('Animated (original SMIL engine)'),
                const SizedBox(height: 8),
                Svgx.string(
                  kConfirmCircleSvg,
                  width: 96,
                  height: 96,
                  // The SVG strokes use `currentColor`; resolve it to orange so
                  // the circle + checkmark draw-on animation is clearly visible.
                  theme: const SvgTheme(currentColor: Color(0xFFFF7A00)),
                ),
                const SizedBox(height: 48),
                const Text('Looping animateTransform (svg-spinners 180-ring)'),
                const SizedBox(height: 8),
                Svgx.string(
                  kSpinnerSvg,
                  width: 96,
                  height: 96,
                  theme: const SvgTheme(currentColor: Color(0xFF2A6DF4)),
                ),
                const SizedBox(height: 48),
                const Text(
                  'Rotation marker (unambiguous animateTransform proof)',
                ),
                const SizedBox(height: 8),
                Svgx.string(kRotationMarkerSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text('Static (Rust usvg path)'),
                const SizedBox(height: 8),
                Svgx.string(kStaticCheckCircleSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text('Static currentColor (theme override → purple)'),
                const SizedBox(height: 8),
                Svgx.string(
                  kStaticCurrentColorSvg,
                  width: 96,
                  height: 96,
                  theme: const SvgTheme(currentColor: Color(0xFF9B30FF)),
                ),
                const SizedBox(height: 48),
                const Text(
                  'paint-order: default (fill then stroke) vs stroke-first',
                ),
                const SizedBox(height: 8),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Svgx.string(
                      _paintOrderCircleSvg(strokeFirst: false),
                      width: 96,
                      height: 96,
                    ),
                    const SizedBox(width: 32),
                    Svgx.string(
                      _paintOrderCircleSvg(strokeFirst: true),
                      width: 96,
                      height: 96,
                    ),
                  ],
                ),
                const SizedBox(height: 48),
                const Text('Embedded <image> (base64 PNG)'),
                const SizedBox(height: 8),
                Svgx.string(kEmbeddedImageSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text(
                  'Named CSS colors (fill="tomato" stroke="cornflowerblue")',
                ),
                const SizedBox(height: 8),
                Svgx.string(kNamedColorsSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text(
                  'Static <g transform="translate() rotate() scale()">',
                ),
                const SizedBox(height: 8),
                Svgx.string(kGroupTransformSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text('animateTransform type="skewX"'),
                const SizedBox(height: 8),
                Svgx.string(kSkewXSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text('<use> of a <defs> shape with its own <animate>'),
                const SizedBox(height: 8),
                Svgx.string(kUseAnimatedSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text('Syncbase begin relay (begin="firstAnim.end")'),
                const SizedBox(height: 8),
                _RestartingSvg(
                  kSyncbaseRelaySvg,
                  width: 96,
                  height: 96,
                  interval: const Duration(seconds: 3),
                ),
                const SizedBox(height: 48),
                const Text('<animateMotion rotate="auto"> along a path'),
                const SizedBox(height: 8),
                Svgx.string(kAnimateMotionSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text('Static linearGradient fill'),
                const SizedBox(height: 8),
                Svgx.string(kLinearGradientSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text('Animated <mask> (Task 1)'),
                const SizedBox(height: 8),
                Svgx.string(kAnimatedMaskSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text('<text> with animated opacity (Task 2)'),
                const SizedBox(height: 8),
                Svgx.string(kTextSvg, width: 160, height: 48),
                const SizedBox(height: 48),
                const Text('<animateMotion keyPoints/keyTimes> (Task 3)'),
                const SizedBox(height: 8),
                Svgx.string(kAnimateMotionKeyPointsSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text('Animated gradient: geometry + stop-color (Task 4)'),
                const SizedBox(height: 8),
                Svgx.string(kAnimatedGradientSvg, width: 96, height: 96),
                const SizedBox(height: 48),
                const Text('Svgx.asset'),
                const SizedBox(height: 8),
                Svgx.asset('assets/icon.svg', width: 96, height: 96),
                const SizedBox(height: 48),
                const Text(
                  'StringSvgx in a DecorationImage — animated spinner, '
                  'rasterized per-frame through ImageProvider (not '
                  'CustomPainter)',
                ),
                const SizedBox(height: 8),
                Container(
                  width: 96,
                  height: 96,
                  decoration: BoxDecoration(
                    image: DecorationImage(
                      image: StringSvgx(
                        kSpinnerSvg,
                        width: 96,
                        height: 96,
                        theme: const SvgTheme(currentColor: Color(0xFF2A6DF4)),
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
0
likes
50
points
69
downloads

Publisher

unverified uploader

Weekly Downloads

High-performance static & animated SVG rendering for Flutter. Rust (usvg) parses, Flutter's own GPU pipeline renders — no CPU rasterizer, SMIL animation support.

Homepage
Repository (GitHub)
View/report issues

Topics

#svg #animation #icons #rust #ffi

License

MIT (license)

Dependencies

flutter, flutter_rust_bridge, meta, plugin_platform_interface, xml

More

Packages that depend on svgx

Packages that implement svgx