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

Universal morphing for stroke-based icons with spring physics — any icon morphs into any other, with rotations that emerge from the math.

stroke_morph #

Universal morphing for stroke-based icons, with spring physics. Any stroke icon morphs into any other — and the rotations, scalings and folds are not authored, they fall out of the math.

An unofficial Dart/Flutter port of morphicons by Guillermo (source). The algorithm and the test invariants are that project's work; this package reimplements them in Dart and adds the Flutter rendering layer. Not affiliated with or endorsed by it. Working in TypeScript? Use the original.

The port mirrors the reference implementation's core module for module and is pinned to it by a cross-language parity suite.

import 'package:stroke_morph_icons/lucide.dart';

MorphIcon(icon: open ? MorphLucideIcons.x : MorphLucideIcons.menu, size: 32)

That is the whole API for the common case: change the icon, and the hamburger folds into a cross at ±45° because that is the shortest way for three horizontal strokes to become two diagonals.

→ Full usage guide — install, the three modes, springs, accessibility, recipes, the lower-level controller, and the gotchas worth knowing before you hit them.

Install #

dependencies:
  stroke_morph_icons: ^0.1.0     # icons AND the engine — one line is enough

stroke_morph_icons re-exports this package, so one dependency and one import give you both the 7 210 icon constants and MorphIcon. Using your own SVGs or d strings instead? Then you only need the engine:

dependencies:
  stroke_morph: ^0.1.0           # engine only, no icon data

No runtime dependencies beyond Flutter itself. See the usage guide for the git-dependency form.

Icon data #

Anything shaped like an SVG stroke icon works — the format is the contract, not a particular pack:

// A raw `d` attribute.
const menu = PathIcon('M4 6h16M4 12h16M4 18h16');

// Lucide-style `[tag, attrs]` data: path, line, circle, ellipse, rect,
// polyline and polygon.
const dot = NodeIcon([IconEl('circle', {'cx': 12, 'cy': 12, 'r': 4})]);

Prefer const: Dart canonicalizes const objects, so the same icon referenced from two places is identical, which is exactly what the driver's caches and change detection key on.

Icons must share a coordinate space. Lucide and Tabler already draw on 24×24; for a pack on another grid, re-grid it once at module scope:

final heroicon = PathIcon(fitIcon(const PathIcon(rawD), 20));

The three modes #

Uncontrolled — change icon, the spring does the rest. Interruptible: a change mid-flight re-plans from the shape currently on screen and carries the velocity across.

MorphIcon(icon: playing ? MorphLucideIcons.pause : MorphLucideIcons.play, preset: SpringPreset.bouncy)

Controlledfrom, to and progress own the path and nothing animates on its own. Drive it from a gesture, a scroll offset, or an Animation.

MorphIcon(from: MorphLucideIcons.menu, to: MorphLucideIcons.x, progress: drawerFraction)

Imperative — hold a GlobalKey<MorphIconState>.

final key = GlobalKey<MorphIconState>();
// …
key.currentState!.morphTo(MorphLucideIcons.check);

Props #

Prop Default
icon uncontrolled mode: the current icon
from / to / progress controlled mode: the frozen pair
preset SpringPreset.snappy smooth, snappy or bouncy
stiffness / damping from preset override either component
duration from preset how long the morph takes; Duration.zero disables the animation
size 24 logical pixels; icons are drawn on a 24×24 grid
color ambient IconTheme the analogue of SVG currentColor
strokeWidth 2 in icon space
absoluteStrokeWidth false keep the on-screen width constant across sizes
label with it the icon is an image to screen readers; without it, hidden

A duration keeps the spring but sets its speed: preset, stiffness and damping then only decide the damping ratio, so preset: bouncy, duration: 900ms is the same bounce stretched to 900 ms. See doc/USAGE.md.

Reduced motion is honoured automatically: with MediaQuery.disableAnimationsOf(context) on, morphTo degrades to an instant set and no frames are scheduled.

Lifecycle contract #

Shared verbatim with the original project's react, react-native, vue and svelte bindings, and pinned by mirrored test suites in each:

  1. Lazy driver. Mounting with no icon is fine — it paints nothing, and the first icon to arrive (a late prop, a late pair, or an imperative call) brings the driver to life without animating. morphTo before there is anything to fly from behaves as set.
  2. Controlled wins. While from and to are both present, the pair owns the path and icon changes are ignored. Drop the pair and icon takes over, animated.
  3. Clean re-entry. Any exit from controlled mode invalidates the frozen pair, so returning to it re-bases on from and renders exactly like a fresh mount at that progress.

Below the widget #

MorphController is the driver, usable on its own — it is a ChangeNotifier that owns a ui.Path:

final morph = MorphController(vsync: this, icon: MorphLucideIcons.menu);
morph.morphTo(MorphLucideIcons.x);           // spring, interruptible
morph.set(MorphLucideIcons.check);           // instant
morph.seek(MorphLucideIcons.check, 0.5);     // frozen at t, no ticker
morph.progress = 0.25;             // ≡ seek on the active target
CustomPaint(painter: MorphPainter(controller: morph, color: c, strokeWidth: 2));

And the pure core is importable without any of the above: resampleIcon, buildPlan, interpPolar, Spring, fitIcon, serialize.

What this port does differently from the web #

Two deliberate divergences, both because Flutter is a better host for this than a DOM:

  • No d string per frame. The web driver must serialize to a string that the browser re-parses; here the interpolated buffers feed ui.Path directly. MorphController.toPathD() still exposes the string for interop.
  • Curve-exact at rest. The web quantizes the resting shape to 4 decimals so SSR bytes match after hydration. Flutter has no hydration step, so the canonical snap emits real cubics.

There is also no hand-rolled frame scheduler: Flutter's SchedulerBinding already multiplexes every Ticker onto one frame callback. A settled icon holds no ticker at all.

Parity with the reference implementation #

The ported suites cover the same invariants as the originals — emergent rotations, endpoint exactness to 1e-9, anchored corners, surjective matching, intrinsic sampling of closed paths, block-transport rigidity — because those assertions are computed properties, not recorded outputs, and so are language-independent.

On top of that, test/parity_test.dart replays vectors dumped from the real TypeScript pipeline and fails if the two disagree: plan fields, interpolated frames at t = 0 / 0.25 / 0.5 / 0.75 / 1 / 1.15, and byte-identical serialized output, over 28 icons and 26 pairs.

The vectors are checked in (test/fixtures/parity-vectors.json), so flutter test needs nothing extra. Regenerating them requires a checkout of the upstream TypeScript project — see tool/dump-vectors.ts for how.

Development #

flutter test                      # core, controller, widget and parity suites
dart analyze --fatal-infos
dart run tool/contact_sheet.dart  # every key pair at every stop, as HTML
cd example && flutter run         # the interactive playground

A morph's quality is validated by eye, not only by asserts. The example app has a scrubber that freezes exactly t = 0.25 / 0.5 / 0.75; the contact sheet is the same thing rendered to a file, for review without a display.

Licence #

MIT, preserving the original morphicons copyright alongside the one for this port. See LICENSE.

0
likes
150
points
25
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Universal morphing for stroke-based icons with spring physics — any icon morphs into any other, with rotations that emerge from the math.

Repository (GitHub)
View/report issues

Topics

#icons #animation #morphing #svg

License

unknown (license)

Dependencies

flutter

More

Packages that depend on stroke_morph