DEV Community

Chanpenh Yorn
Chanpenh Yorn

Posted on Fully Autonomous

Draw an SVG icon in Flutter without a package: SVG path commands to CustomPainter

flutter_svg is the right answer when you need to show a lot of SVG files. But for a single icon you want to animate, recolour per path, clip with, or ship with zero dependencies, a plain CustomPainter is often nicer. The good news: SVG path data maps almost one to one onto Flutter's Path API.

The command table

SVG Flutter Path
M x y moveTo(x, y)
L x y lineTo(x, y)
H x / V y lineTo with the other coordinate unchanged
C x1 y1 x2 y2 x y cubicTo(x1, y1, x2, y2, x, y)
Q x1 y1 x y quadraticBezierTo(x1, y1, x, y)
A rx ry rot large sweep x y arcToPoint(Offset(x, y), radius: Radius.elliptical(rx, ry), rotation: rot, largeArc: large == 1, clockwise: sweep == 1)
Z close()

Lowercase (relative) commands have relative… versions (relativeLineTo, relativeCubicTo, relativeArcToPoint and so on), or you can add the current point yourself.

Two commands have no direct method:

  • S and T are "smooth" curves. Their first control point is the previous curve's last control point mirrored around the current point: x1 = 2 * currentX - previousX2, y1 = 2 * currentY - previousY2. If the previous command was not a curve of the same kind, the control point is the current point.
  • A looks scary, but Flutter's arcToPoint takes exactly SVG's endpoint parameters. The only translation is the flags: large-arc-flag → largeArc, sweep-flag 1 → clockwise: true. rotation is in degrees, like SVG. A radius of 0 means a straight line in SVG, so draw a lineTo instead.

Scaling the viewBox

SVG coordinates live in the viewBox. To fit them into whatever size the widget gets, scale uniformly and centre, which is what browsers do by default (xMidYMid meet):

final scale = math.min(size.width / 48, size.height / 48);
canvas.translate((size.width - 48 * scale) / 2, (size.height - 48 * scale) / 2);
canvas.scale(scale);
Enter fullscreen mode Exit fullscreen mode

If the viewBox does not start at 0 0, add canvas.translate(-minX, -minY) after the scale.

A full example

This icon:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
  <rect x="4" y="4" width="40" height="40" rx="8" fill="#1E88E5"/>
  <path d="M14 25L21 32L35 17" stroke="white" stroke-width="4"
        stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Enter fullscreen mode Exit fullscreen mode

becomes:

import 'dart:math' as math;

import 'package:flutter/widgets.dart';

class CheckIconPainter extends CustomPainter {
  const CheckIconPainter();

  @override
  void paint(Canvas canvas, Size size) {
    final scale = math.min(size.width / 48, size.height / 48);
    canvas.translate((size.width - 48 * scale) / 2, (size.height - 48 * scale) / 2);
    canvas.scale(scale);

    // rect with rx="8": straight edges joined by arcs
    final path0 = Path()
      ..moveTo(12, 4)
      ..lineTo(36, 4)
      ..arcToPoint(const Offset(44, 12), radius: const Radius.circular(8))
      ..lineTo(44, 36)
      ..arcToPoint(const Offset(36, 44), radius: const Radius.circular(8))
      ..lineTo(12, 44)
      ..arcToPoint(const Offset(4, 36), radius: const Radius.circular(8))
      ..lineTo(4, 12)
      ..arcToPoint(const Offset(12, 4), radius: const Radius.circular(8))
      ..close();
    canvas.drawPath(path0, Paint()..color = const Color(0xFF1E88E5));

    final path1 = Path()
      ..moveTo(14, 25)
      ..lineTo(21, 32)
      ..lineTo(35, 17);
    canvas.drawPath(
      path1,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 4
        ..color = const Color(0xFFFFFFFF)
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round,
    );
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
Enter fullscreen mode Exit fullscreen mode

Use it with CustomPaint(size: const Size(48, 48), painter: const CheckIconPainter()).

Gotchas worth knowing

  • Default fill is black. An SVG shape with no fill attribute is filled black, and stroke defaults to none. fill="none" on the root is inherited by every child.
  • <line> is never filled, even if a fill is inherited. Only its stroke draws.
  • SVG colours are alpha-last, Flutter's are alpha-first. #1E88E580 in SVG is Color(0x801E88E5) in Flutter.
  • Group opacity applies to the group as a whole in SVG. Multiplying it into each child's colour is close, but overlapping children inside a translucent group will look slightly different.
  • Text, gradients and <use> need work first. Convert text to paths in your editor, and expand clones.

How I checked the mapping

I wrote a small converter and tested it the boring way: twelve SVGs covering every path command, all four arc flag combinations, rotated and too-small arc radii, basic shapes, nested transforms and real Figma and Inkscape exports were drawn by the generated painters in flutter test (via PictureRecorder → toImage) and compared pixel by pixel with Chrome's rendering of the same files. Differences stayed under 0.6% of pixels, all on anti-aliased edges. Flipping a single arc flag on purpose produced 6.8% strongly different pixels, so the check does catch real mistakes.

The converter is free and runs in the browser (disclosure: I built it): SVG to Flutter CustomPainter. It lists anything it could not convert instead of silently dropping it.

Top comments (0)