text_morph_flutter 1.1.0
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 onesampleSpacinginterval) 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_matchByProximityboth rely on) no longer lets a contour with corrupted (NaN)signedAreametadata — possible on a hand-authoredShapeSourcecontour, since_boundedContouronly 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_matchByProximityalready used for its own area inputs.
Docs #
MorphOptions.idlePulseAmount's doc now notes that aNaNvalue, while safe to construct and to render (it degrades to no pulse), makes the wholeMorphOptionsinstance permanently unequal to itself under==(per IEEE 754,double.nan == double.nanisfalse), which defeatsMorph'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 fromt = 0on every such rebuild. This isn't unique toidlePulseAmount: every other numericMorphOptionsfield relies on anassert-only range check, compiled out of release builds, so the same effect can reach any of them too — noted onoperator ==itself now, not justidlePulseAmount.
Cleanup #
PathMorph._classifyHoles'snearestParentArea == 0branch is now genuinely unreachable (see Fixed above: with areas sanitized, nothing can be "strictly bigger than"0except a real positive area, sonearestParentAreaitself can never become exactly0) and has been removed. An earlier attempt to remove this same branch, before areas were sanitized, was reverted after review foundareas[i]could still reach here as an unsanitizedNaN— 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 singlePath.combinecall ever operates on more than two same-sized operands) instead of unioning them one at a time into an ever-growing per-cluster accumulator. Astagger-driven reflow can make a real, connected chain of touching glyphs grow to a sizable fraction of the whole string, andPath.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 atstagger: 0.8dropped from 32.1s to 1.08s across a full 61-frame transition, in line withstagger: 0's 0.91s for the same glyph count (see issue #5).
Erratum #
- The
1.0.0entry below onTextMorph'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-transitionpathAtcall, 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-transitionpathAtcall 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. Changingtarget(e.g. viasetState) diffs it against whatever is currently displayed and animates between them, continuing smoothly from the on-screen shape if a newtarget/style/stagger/options/fontSize/fontchange interrupts a transition already in flight.TextMorph/PathMorph, the lower-level, widget-free geometry engineMorphis built on, exported for callers who want to drive their ownCustomPainter.MorphSource, a morph endpoint, with two implementations:TextSource— renders a string in aglyph_pathFont.textmay contain explicit\nline breaks, andmaxWidthadditionally word-wraps against a fixed width; both flow through the same per-glyph alignment/stagger/caching pipeline as single-line text.TextSource.rtlScriptopts 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 ofglyph_pathContours) fitted to the surrounding text's size, sotargetcan switch betweenTextSourceandShapeSourcefreely using the same underlying mechanism.ShapeSource.fromPathbuilds one directly from adart:uiPathby sampling it viaPathMetric, sincePathdoesn't expose the drawing commands it was built from.
MorphOptions, tuning knobs layered underMorphStyleand 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), orwholePath(pools both strings' contours by area rank, skipping per-glyph pairing entirely — useful for text↔complex-shape morphs, at the cost ofstaggerhaving no effect).MorphStyle,shape(always morph outlines),crossFade(never morph outlines, fade in place instead), orauto(shape-morph glyph pairs similar enough perdissimilarityThresholdand the optionalmatchedShapeDissimilarityThreshold, cross-fade the rest).
Bidi text #
TextSource.embedLtr, for marking a substring ofrtlScripttext 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), whichTextSourcestrips back out before layout.
Styling & layout #
Morph.color/strokeColor/strokeWidthmorph 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 whenstrokeColorchanges to/fromnull.Morph.paintBuilder/strokePaintBuilderlet a caller build its ownPaint(gradients, shadows) that morphs the same way, given(Rect bounds, double t).Morph.textAlign— horizontal alignment (left/right/center;start/endresolve against the ambientDirectionality,justifyfalls back tostart). For multi-lineTextSourcecontent, 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 toTextAlign.center.TextMorph.between'sfromFontSizeparameter letsfromresolve at a different size thanto, so atarget/fontSizechange animates the size alongside the shape instead of the two only ever being compared at one shared size.Morphrespects the ambientMediaQuery.textScaler, the same wayTextdoes.
Accessibility #
Morphreports its currenttarget's text as aSemanticslabel (aShapeSourcetarget can supply one explicitly viasemanticLabel, exposed the same way asTextSource.semanticLabel).MorphhonorsMediaQuery.disableAnimations("reduce motion"): a transition still completes, but in a single frame instead of animating overduration.Morph.announceChanges, an opt-in flag (Semantics.liveRegion) for atargetchange that happens off-focus, e.g. a live search preview.Morph.highContrastColor/highContrastStrokeColor/highContrastStrokeWidth, opt-in overrides forcolor/strokeColor/strokeWidththat 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.highContrastStrokeColoralone can add an outline under high contrast to a shape that's otherwise strokeless.
Performance & caching #
MorphCache, an opt-in,cacherine-backed cache forPathMorph's one-time contour pairing/alignment cost, reused across repeated transitions between the same glyph pair(s) under the sameMorphOptions— aimed at high-churn content like a dashboard numeric ticker or a search-suggestion list. Pass one toMorph.cacheorTextMorph.between'scacheparameter; the default (null) never caches. Internally split into two independently-sized pools — one for per-glyph entries (maxSize, default64), one for whole-transition ("wholePath") entries (maxWholePathSize, default8) — 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 explicitGlyphAlignment.wholePath: any transitionTextMorphcan't align by glyph (e.g. aShapeSourceon either side) or silently downgrades for being too long also lands there.MorphCache.perGlyphSize/wholePathSizereport each pool's count on its own, since the existingsize(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(default256 * 1024 * 1024, calibrated against measured process memory growth for real wholePath content — seeestimateWholePathBytes'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 builtPathMorph's actualui.Paths are opaque, natively-backed Skia objects with no queryable size — sums past the limit, independent ofmaxWholePathSize. This directly addresses the riskmaxWholePathSizealone couldn't: a handful of unusually heavy wholePath entries (e.g. long strings, or detailedShapeSources) no longer accumulate unbounded memory just because they're still under the entry-count cap.MorphCache.wholePathBytesreports the pool's current combined weight.MorphCache's singlegetOrBuild(@internal) is now two separate methods,getOrBuildPerGlyph/getOrBuildWholePath— the latter takesweightBytesas 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 evadesmaxWholePathBytesentirely.- Settled-endpoint caching at two layers, so only geometry still actively
transitioning is recomputed each frame:
TextMorphcaches a glyph slot's positioned outline (plus the union of every currently-settled slot) once itsstaggerwindow reaches0/1;PathMorphcaches a contour pair's interpolated outline once its own windowed progress settles, which can happen mid-transition whencontourTimingOffsetis 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 finesampleSpacingcan't block the UI thread with unboundedO(n·m)/O(n²)/O(n³)work. PathMorph's settled-endpoint contour cache (a contour pinned at0/1mid-transition bycontourTimingOffset) now also caches theui.Pathand bounds built from that contour, so per-frame compositing (_solidsMinusHoles) reuses them instead of replaying the same settled contour's commands into a freshPathand re-measuring it every frame.TextMorph's per-frame slot compositing now scopes eachPath.combineunion to the specific cluster of overlapping slots it belongs to, mirroringPathMorph._solidsMinusHoles's clustering, instead of unioning against one ever-growing accumulator over every slot placed so far — a non-zerostaggerused 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).Morphnow readshighContrast/disableAnimationsviaMediaQuery's aspect-scoped accessors (MediaQuery.highContrastOf/MediaQuery.disableAnimationsOf) instead ofMediaQuery.of, which subscribed to everyMediaQueryDataaspect — an unrelated change (e.g.viewInsetsduring 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.