text_morph_flutter 1.1.0 copy "text_morph_flutter: ^1.1.0" to clipboard
text_morph_flutter: ^1.1.0 copied to clipboard

Shape-level text morphing animations for Flutter, built on glyph_path and glyph_path_flutter — animates one string's glyph outlines into another's.

Changelog #

All notable changes to this project will be documented in this file.

This project adheres to Semantic Versioning.

1.1.0 #

Fixed #

  • ShapeSource.fromPath's sampling no longer wastes a vertex sampling a closed sub-path's own start point a second time at the end (the two coincide on a closed loop, and a synthetic closing edge already reconnects them) — an open sub-path's real, distinct endpoint is still sampled as before, and a very short closed sub-path (under one sampleSpacing interval) still keeps at least two samples instead of being silently dropped entirely.
  • PathMorph._classifyHoles's hole-vs-solid classification (and _sortedByArea's ordering, which it and _matchByProximity both rely on) no longer lets a contour with corrupted (NaN) signedArea metadata — possible on a hand-authored ShapeSource contour, since _boundedContour only sanitizes non-finite coordinates, not a merely-corrupted area field — permanently block a real, legitimate hole from ever finding its actual parent contour, or jump ahead of a real, larger contour when a role group is large enough to be truncated. Both now apply the same _sanitizedArea _matchByProximity already used for its own area inputs.

Docs #

  • MorphOptions.idlePulseAmount's doc now notes that a NaN value, while safe to construct and to render (it degrades to no pulse), makes the whole MorphOptions instance permanently unequal to itself under == (per IEEE 754, double.nan == double.nan is false), which defeats Morph's rebuild-skipping value-equality check and forces a full morph pipeline rebuild on every unrelated parent rebuild — including, while a transition is already animating, restarting it from t = 0 on every such rebuild. This isn't unique to idlePulseAmount: every other numeric MorphOptions field relies on an assert-only range check, compiled out of release builds, so the same effect can reach any of them too — noted on operator == itself now, not just idlePulseAmount.

Cleanup #

  • PathMorph._classifyHoles's nearestParentArea == 0 branch is now genuinely unreachable (see Fixed above: with areas sanitized, nothing can be "strictly bigger than" 0 except a real positive area, so nearestParentArea itself can never become exactly 0) and has been removed. An earlier attempt to remove this same branch, before areas were sanitized, was reverted after review found areas[i] could still reach here as an unsanitized NaN — see #13.

Performance #

  • TextMorph's per-frame slot compositing now combines each cluster's overlapping pieces via a binary-carry merge (each new piece ripples through like incrementing a binary counter, so no single Path.combine call ever operates on more than two same-sized operands) instead of unioning them one at a time into an ever-growing per-cluster accumulator. A stagger-driven reflow can make a real, connected chain of touching glyphs grow to a sizable fraction of the whole string, and Path.combine's cost scales with the receiving path's accumulated complexity regardless of how tightly clustering already scopes it — so that chain's own union cost used to still scale superlinearly even though it was correctly scoped to just the glyphs that actually overlap. 640 glyphs at stagger: 0.8 dropped from 32.1s to 1.08s across a full 61-frame transition, in line with stagger: 0's 0.91s for the same glyph count (see issue #5).

Erratum #

  • The 1.0.0 entry below on TextMorph's per-frame slot compositing compared two measurements taken under different conditions as if they were the same: "640 glyphs took 6.3s" was a single mid-transition pathAt call, but "roughly 280ms for the same case" was the total across a full 61-frame transition — not a like-for-like before/after. Measured the same way on each side of that fix (byIndex, stagger: 0.8): a single mid-transition pathAt call for 640 glyphs went from 6.3s to 55ms; the full 61-frame transition total, which the "roughly 280ms" figure was meant to describe, actually still took roughly 32.1s at that point — the residual issue fixed above (see #5/#7).

1.0.0 #

Initial release.

Core #

  • Morph, a widget that animates one string's glyph outlines into another's — or into an arbitrary vector shape — instead of cross-fading. Changing target (e.g. via setState) diffs it against whatever is currently displayed and animates between them, continuing smoothly from the on-screen shape if a new target/style/stagger/options/ fontSize/font change interrupts a transition already in flight.
  • TextMorph / PathMorph, the lower-level, widget-free geometry engine Morph is built on, exported for callers who want to drive their own CustomPainter.
  • MorphSource, a morph endpoint, with two implementations:
    • TextSource — renders a string in a glyph_path Font. text may contain explicit \n line breaks, and maxWidth additionally word-wraps against a fixed width; both flow through the same per-glyph alignment/stagger/caching pipeline as single-line text. TextSource.rtlScript opts a pure, non-cursive right-to-left string (the realistic case: Hebrew) into mirrored glyph order and paired-punctuation swapping (()/[]/{}/<>/«»/‹›) — see Known limitations for exactly what this does and doesn't cover.
    • ShapeSource — renders an arbitrary vector shape (a list of glyph_path Contours) fitted to the surrounding text's size, so target can switch between TextSource and ShapeSource freely using the same underlying mechanism. ShapeSource.fromPath builds one directly from a dart:ui Path by sampling it via PathMetric, since Path doesn't expose the drawing commands it was built from.
  • MorphOptions, tuning knobs layered under MorphStyle and stagger (contour timing offset, hole-area threshold, dissimilarity thresholds, alignment strategy, idle pulse, wobble — see the README's MorphOptions reference for the full field list) — value-comparable (==/hashCode) so passing a freshly-built but equal instance doesn't interrupt an in-progress transition.
  • GlyphAlignment, how glyphs from the two strings are paired when they don't correspond one-to-one: byIndex (purely positional), diff (the default — minimizes pop-in/out and keeps identical characters in place), or wholePath (pools both strings' contours by area rank, skipping per-glyph pairing entirely — useful for text↔complex-shape morphs, at the cost of stagger having no effect).
  • MorphStyle, shape (always morph outlines), crossFade (never morph outlines, fade in place instead), or auto (shape-morph glyph pairs similar enough per dissimilarityThreshold and the optional matchedShapeDissimilarityThreshold, cross-fade the rest).

Bidi text #

  • TextSource.embedLtr, for marking a substring of rtlScript text as an embedded left-to-right run (e.g. a Latin brand name or phone number inside an otherwise-RTL sentence) — its own glyph order stays intact while it still takes its place in the surrounding line's mirroring, the same as a real bidi engine's left-to-right isolate. Wraps the text in the Unicode left-to-right isolate pair (U+2066/U+2069), which TextSource strips back out before layout.

Styling & layout #

  • Morph.color/strokeColor/strokeWidth morph over the same duration/timeline as the shape, including continuing correctly from the actual on-screen color/stroke if a change interrupts one already in flight, and fading smoothly in/out when strokeColor changes to/from null. Morph.paintBuilder/strokePaintBuilder let a caller build its own Paint (gradients, shadows) that morphs the same way, given (Rect bounds, double t).
  • Morph.textAlign — horizontal alignment (left/right/center; start/end resolve against the ambient Directionality, justify falls back to start). For multi-line TextSource content, the same value also controls inter-line alignment, so a paragraph's internal alignment always matches how the whole block is placed in its box. Defaults to TextAlign.center.
  • TextMorph.between's fromFontSize parameter lets from resolve at a different size than to, so a target/fontSize change animates the size alongside the shape instead of the two only ever being compared at one shared size.
  • Morph respects the ambient MediaQuery.textScaler, the same way Text does.

Accessibility #

  • Morph reports its current target's text as a Semantics label (a ShapeSource target can supply one explicitly via semanticLabel, exposed the same way as TextSource.semanticLabel).
  • Morph honors MediaQuery.disableAnimations ("reduce motion"): a transition still completes, but in a single frame instead of animating over duration.
  • Morph.announceChanges, an opt-in flag (Semantics.liveRegion) for a target change that happens off-focus, e.g. a live search preview.
  • Morph.highContrastColor/highContrastStrokeColor/highContrastStrokeWidth, opt-in overrides for color/strokeColor/strokeWidth that take effect while the platform's "increase contrast" setting (MediaQuery.highContrast) is on — including a runtime flip of the setting itself, morphed the same way a plain color change is. highContrastStrokeColor alone can add an outline under high contrast to a shape that's otherwise strokeless.

Performance & caching #

  • MorphCache, an opt-in, cacherine-backed cache for PathMorph's one-time contour pairing/alignment cost, reused across repeated transitions between the same glyph pair(s) under the same MorphOptions — aimed at high-churn content like a dashboard numeric ticker or a search-suggestion list. Pass one to Morph.cache or TextMorph.between's cache parameter; the default (null) never caches. Internally split into two independently-sized pools — one for per-glyph entries (maxSize, default 64), one for whole-transition ("wholePath") entries (maxWholePathSize, default 8) — since the two hold entries of very different weight (per-glyph entries scale with character variety rather than candidate count, while wholePath entries are far heavier per transition); filling one pool no longer evicts entries from the other. The wholePath pool isn't limited to an explicit GlyphAlignment.wholePath: any transition TextMorph can't align by glyph (e.g. a ShapeSource on either side) or silently downgrades for being too long also lands there. MorphCache.perGlyphSize/wholePathSize report each pool's count on its own, since the existing size (now a combined total) can no longer answer "is my per-glyph pool sized correctly" once a cache holds both kinds of entry. The wholePath pool additionally bounds its combined estimated weight, not only its entry count: maxWholePathBytes (default 256 * 1024 * 1024, calibrated against measured process memory growth for real wholePath content — see estimateWholePathBytes's doc) evicts least-recently-used entries once a rough, deliberately approximate per-entry weight estimate — scaled from the transition's total input contour command count, since the built PathMorph's actual ui.Paths are opaque, natively-backed Skia objects with no queryable size — sums past the limit, independent of maxWholePathSize. This directly addresses the risk maxWholePathSize alone couldn't: a handful of unusually heavy wholePath entries (e.g. long strings, or detailed ShapeSources) no longer accumulate unbounded memory just because they're still under the entry-count cap. MorphCache.wholePathBytes reports the pool's current combined weight. MorphCache's single getOrBuild (@internal) is now two separate methods, getOrBuildPerGlyph/getOrBuildWholePath — the latter takes weightBytes as a required positional parameter rather than an optional/defaulted one, so a future wholePath call site can't silently forget it and admit a zero-weight entry that evades maxWholePathBytes entirely.
  • Settled-endpoint caching at two layers, so only geometry still actively transitioning is recomputed each frame: TextMorph caches a glyph slot's positioned outline (plus the union of every currently-settled slot) once its stagger window reaches 0/1; PathMorph caches a contour pair's interpolated outline once its own windowed progress settles, which can happen mid-transition when contourTimingOffset is non-zero.
  • PathMorph's hole-nesting classification checks a cheap bounding-box containment before paying for the O(vertices) ray-cast it used to run unconditionally for every contour pair.
  • Built-in ceilings on glyph count, contour-pool size, per-contour vertex count, and ShapeSource.fromPath's path-sampling density, each falling back to a cheaper approximation once exceeded — so a very long string, a high-vertex-count shape, or a very fine sampleSpacing can't block the UI thread with unbounded O(n·m)/O(n²)/O(n³) work.
  • PathMorph's settled-endpoint contour cache (a contour pinned at 0/1 mid-transition by contourTimingOffset) now also caches the ui.Path and bounds built from that contour, so per-frame compositing (_solidsMinusHoles) reuses them instead of replaying the same settled contour's commands into a fresh Path and re-measuring it every frame.
  • TextMorph's per-frame slot compositing now scopes each Path.combine union to the specific cluster of overlapping slots it belongs to, mirroring PathMorph._solidsMinusHoles's clustering, instead of unioning against one ever-growing accumulator over every slot placed so far — a non-zero stagger used to make this scale superlinearly with glyph count (640 glyphs took 6.3s for a single frame); it now stays close to linear (roughly 280ms for the same case, across a full 61-frame transition).
  • Morph now reads highContrast/disableAnimations via MediaQuery's aspect-scoped accessors (MediaQuery.highContrastOf/ MediaQuery.disableAnimationsOf) instead of MediaQuery.of, which subscribed to every MediaQueryData aspect — an unrelated change (e.g. viewInsets during a keyboard show/hide animation) no longer triggers a rebuild.

Known limitations #

See the README's Known limitations section for the current state of RTL/bidi text support, accessibility coverage, and performance scaling.

0
likes
160
points
117
downloads
screenshot

Documentation

API reference

Publisher

verified publishercrossapplication.members.co.jp

Weekly Downloads

Shape-level text morphing animations for Flutter, built on glyph_path and glyph_path_flutter — animates one string's glyph outlines into another's.

Repository (GitHub)
View/report issues

Topics

#animation #text #transition #shape #vector-graphics

License

BSD-3-Clause (license)

Dependencies

cacherine, flutter, glyph_path, glyph_path_flutter, meta

More

Packages that depend on text_morph_flutter