Expand description
M3E pull-to-refresh: a wrapper around a scrollable child that shows the
crate::loading_indicator spinner while the app’s async refresh
runs, triggered by pulling past the baseline’s own overscroll trigger.
§Architecture: this widget owns the frust::scroll_view
Upstream wraps an app-supplied Scrollable descendant via
NotificationListener, observing whatever scroll surface happens to sit
underneath. The framework’s public surface has no notification-bubbling
seam like that — the two seams a scroll surface exposes are
frust::ScrollView::on_scroll and
frust::ScrollView::on_refresh_release, both installed on the view at
construction, not observed after the fact. So refresh_indicator takes
the child content (not an already-built ScrollView) and builds the
scroll_view itself, installing both callbacks internally — the same
shape crate’s frust_shadcn::scroll_area precedent uses for the one
other in-tree wrapper over this baseline.
§Pull-progress mapping
Upstream’s drag mapping normalizes the raw drag delta against 25% of the
scrollable’s containerExtent (_checkDragOffset,
m3e_refresh_indicator_scroll.dart:248-279) — a value ScrollInfo does
not carry (no viewport dimension crosses this framework’s seam). The port
maps ScrollInfo::overscroll (already resistance-applied by
ScrollView’s own rubber-band, negative past the top) directly in
logical px instead: [visual_pull] clamps -overscroll to
displacement + indicator_height, the same “resting refresh position”
target upstream’s own curve converges on. This is a simpler curve, not
a functionality loss — document, not a LIMITATIONS.md entry.
§Refresh lifecycle: controlled
refresh_indicator’s on_refresh callback fires exactly once per pull
released past the baseline’s trigger — frust::ScrollView’s own
REFRESH_TRIGGER_PX (64px, post-resistance) is the arming threshold, not
a value this crate restates. Unlike upstream’s Future-returning
onRefresh, which the indicator itself awaits via a Completer, this
port cannot hold an arbitrary async task inside a widget (there is no
widget-local executor seam — see docs/CORE_ARCHITECTURE.md’s
spawn/spawn_blocking routing). The lifecycle is controlled
instead, matching this crate’s Checkbox/Slider convention: on_refresh
returns control to the app immediately; the app flips its own
refreshing state to true (typically inside on_refresh, spawning the
real async work alongside it) and feeds it back through
RefreshIndicatorView::refreshing on the next rebuild. The indicator
shows its spinner for as long as refreshing reads true, and only ends
it (scaling away) on a rebuild where the app confirms refreshing = false — an app that never flips it back leaves the spinner showing
forever, the same contract violation an unconfirmed Checkbox toggle is.
A second release-past-trigger while a refresh is already in flight does
not re-invoke on_refresh (in_flight, guarded at the callback
itself) — the underlying scroll content stays interactive during a
refresh (see the next section), so a fast second pull is a real,
reachable case, not a hypothetical one.
§Documented seam gap: the scroll offset is not held during a refresh
Upstream visually “holds” the pulled content at the indicator’s resting
offset for the whole refresh, tied to the same _positionController that
drives the indicator. ScrollView’s public seam has no “pin the offset
until released” affordance — its own release-settle always eases back to
the clamped edge once the pointer lifts, independent of whatever this
wrapper is doing. The port accepts this: the indicator overlays its own
resting position via Widget::paint-time compositing (translate +
optional scale, frust::authoring::PaintScene::push_transform) while
the scroll content underneath is free to settle and scroll normally
during a refresh. FINDING candidate: a ScrollView seam to suppress
the release-settle until a caller releases it would close this gap
properly; per the plugin charter this crate does not patch
frust-widgets to add it.
A second, narrower gap: frust::ScrollView::on_scroll delivers an
animation-driven change (the release-settle) one event late
(documented on that method) rather than every paint frame, so this
widget’s own reveal never rides the baseline’s settle motion — it runs
its own snap/retract AnimationControllers from the pull distance
observed at release/cancel instead, independent of whatever the
underlying surface does afterward.
§Variants
refresh_indicator is the expressive default
(crate::LoadingIndicatorVariant::Default);
RefreshIndicatorView::contained switches to
crate::LoadingIndicatorVariant::Contained (both delegate their
MaterialTokens color resolution to crate::loading_indicator
itself — this module never overrides a color). Upstream’s material
and adaptive variants (RefreshProgressIndicator,
CupertinoActivityIndicator) are out of scope: only the two M3E-shaped
ones plus RefreshIndicatorView::no_spinner (upstream’s
M3ERefreshIndicator.noSpinner — the trigger/lifecycle contract with no
visual at all, for an app that draws its own affordance elsewhere) are
ported.
Structs§
- Refresh
Indicator View - A declarative M3E pull-to-refresh wrapper. See the module docs.
- Refresh
Indicator Widget - The retained widget for a
RefreshIndicatorView. See the module docs.
Functions§
- refresh_
indicator - Wrap
childin a pull-to-refresh surface, firingon_refreshonce per pull released past the baseline’s trigger. See the module docs for the controlledrefreshinglifecycle contract.