Expand description
The Material 3 Expressive loading indicator: a continuously-morphing,
continuously-rotating sequence of seven Material shapes, driven by the
crate’s Morph feature-point engine.
§Attribution
Ported from paadevelopments/material_3_expressive v1.0.8 (MIT, © 2026
Paa Developments), lib/components/loading_indicator/ — whose
components/m3e_expressive_loading_indicator.dart is itself, per that
file’s own header, vendored verbatim from the loading_indicator_m3e
package, a Dart port of AndroidX Compose Material3’s
LoadingIndicator.kt
(androidx/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt,
© 2024 The Android Open Source Project, Apache License 2.0). The shape
sequence, timing constants, spring, and rotation choreography below are
transcribed from that Apache-2.0-licensed logic, retargeted onto this
crate’s own shapes::Morph/RoundedPolygon engine rather than Flutter’s.
The container variant and its theme defaults
(lib/components/loading_indicator/{m3e_loading_indicator,styles/m3e_loading_indicator_theme}.dart)
are the MIT-licensed material_3_expressive wrapper around it. See
plugins/material/NOTICE’s Apache License, Version 2.0 — The Android
Open Source Project section (this module activates its
previously-planned loading-indicator entry).
§Shape sequence
[SHAPE_CYCLE] is upstream’s _defaultPolygons
(m3e_expressive_loading_indicator.dart:57-65): softBurst,
cookie9Sided, pentagon, pill, sunny, cookie4Sided, oval, each
resolved through this crate’s 35-shape ShapeKind catalog. [morph_sequence]
builds the circular run of Morphs between consecutive shapes
(_createMorphSequence(polygons, circularSequence: true),
m3e_expressive_loading_indicator.dart:195-211) once and shares it across
every indicator instance — building a Morph runs the expensive
corner-measure/match pipeline (see Morph’s own cost note).
§Choreography
Two independent clocks drive the paint, matching
m3e_expressive_loading_indicator.dart:96-166’s build:
- Per-cycle morph. Every [
PER_SHAPE_PERIOD] (650ms — upstream’s_morphIntervalMs), the indicator advances to the next morph in [morph_sequence] and restarts a spring-driven progress from0toward1— [LOADING_SPRING] (M3EMotion.expressiveSpatialDefault,m3e_motion.dart:104-107, promoted to this crate’s sharedMaterialSpring::EXPRESSIVE_SPATIAL_DEFAULTtoken) released with [MORPH_INITIAL_VELOCITY] (SpringSimulation(spring, 0, 1, 5, snapToEnd: true),m3e_expressive_loading_indicator.dart:85-91). - Continuous background rotation. A separate
0.0..1.0loop repeats every [GLOBAL_ROTATION_PERIOD] (4666ms — upstream’s_globalRotationDurationMs), contributing up to a full turn.
The paint reads both, plus a per-cycle accumulator
(rotation_target_deg, upstream’s _morphRotationTargetAngle) that steps
by [QUARTER_ROTATION_DEG] (90°) modulo [FULL_ROTATION_DEG] every
cycle, and sums them exactly as upstream’s totalRotationDegrees does —
so the shape visibly spins a quarter-turn per morph on top of the slow
continuous background spin.
§Initial cycle
Upstream’s initState calls _startMorphCycle() once, synchronously,
before the first frame ever paints (m3e_expressive_loading_indicator.dart:192,259).
That call already advances _currentMorphIndex from 0 to 1 and
_morphRotationTargetAngle from 90° to 180° — so the very first shape
ever shown is [SHAPE_CYCLE][1] (cookie9Sided), not [0]
(softBurst); [0] only reappears later in the loop. LoadingIndicatorWidget::advance_cycle
replays this by running once at the end of build, matching the quirk
exactly rather than “fixing” it.
§Geometry
Every catalog shape is normalized into (0,0)..(1,1)
(crate::shapes::RoundedPolygon::normalized), so a raw Morph::path output needs
scaling and centering before it paints. [shape_cycle_scale_factor] is
upstream’s _calculateScaleFactor
(m3e_expressive_loading_indicator.dart:213-247): since the shape
rotates, sizing it to fit only its own (unrotated) bounds would clip a
corner as it turns, so the scale is instead derived from the worst-case
ratio between each shape’s axis-aligned bounds and its maximum
any-rotation bounds (crate::shapes::RoundedPolygon::max_bounds) across the whole
cycle. [process_path] applies that scale (further scaled by
[ACTIVE_INDICATOR_SIZE] against the container box — upstream’s
activeIndicatorScale), centers the result, and rotates it about that
same center — the upstream _MorphPainter._processPath scale+center
composed with the enclosing Transform.rotate
(m3e_expressive_loading_indicator.dart:144-159, 319-340).
§Variants
LoadingIndicatorVariant::Default paints only the floating shape
(containerColorDefault, fully transparent — no background is drawn at
all). LoadingIndicatorVariant::Contained additionally fills a
fully-rounded container the same size as the indicator box behind it,
colors.primary_container/colors.on_primary_container in place of
colors.primary — upstream’s M3ELoadingIndicatorTheme
(m3e_loading_indicator_theme.dart). The container’s radius (999,
i.e. always fully rounded at this box size) and its zero default padding
mean the indicator’s own geometry is unchanged between variants.
§Dropped frames
The per-cycle boundary is detected the same way the pre-rework module
detected it: a linear 0.0..1.0 repeat whose wrap (this frame’s phase <
last frame’s) triggers LoadingIndicatorWidget::advance_cycle, rather
than a counted timer callback. A dropped frame longer than one full period
therefore skips a cycle visually rather than catching every intermediate
one — acceptable for a decorative loading spinner, and it always
self-corrects on the next frame.
Structs§
- Loading
Indicator View - A Material 3 Expressive loading indicator. See the module docs.
- Loading
Indicator Widget - The retained widget for a
LoadingIndicatorView. See the module docs.
Enums§
- Loading
Indicator Variant - The indicator’s visual variant — upstream’s
M3ELoadingIndicatorVariant(m3e_loading_indicator_variant.dart).
Functions§
- Loading
Indicator - PascalCase alias for
loading_indicator, matching the widget-fn vocabulary (Button/Image/…). - loading_
indicator - Create a loading indicator (
LoadingIndicatorVariant::Default).