-
Notifications
You must be signed in to change notification settings - Fork 0
LayrzRefreshIndicator
A loading affordance reporting a refresh lifecycle above a scrollable region.
Metadata
Predecessor: None — no ThemedRefreshIndicator exists in layrz_theme
Phase: M6 (pulled forward as an M4/M5 prerequisite)
Domain: Feedback
Primitive: Hand-rolled (SizeTransition/CustomPaint, no Material RefreshIndicator)
Status: Merged · Review required.
Read this before anything else, because the class name invites the wrong assumption.
Despite the name, the deliverable here is the loading affordance itself — a controller, a state
machine, and a visual — not the pull-to-refresh drag gesture. LayrzRefreshController.refresh()
is the always-available, primary entry point; the optional LayrzRefreshGestureDetector gives
touch users the familiar pull affordance as a second path into the exact same controller call.
This is deliberate, not an afterthought: layrz_ui is desktop-first, and a drag gesture needs a
finger. A caller on desktop drives the same indicator from a button, a keyboard shortcut, or any
other app logic — there is no requirement to fake or route around a drag gesture to show the
loading state.
final controller = LayrzRefreshController();
LayrzRefreshIndicator(
controller: controller,
onRefresh: () async => api.reloadData(),
child: ListView(children: [...]),
)
// Elsewhere — a button drives the exact same loading affordance, no drag at all:
LayrzButton(
labelText: 'Refresh',
onTap: () => controller.refresh(() => api.reloadData()),
)LayrzRefreshGestureDetector (wired up by default via enableDragGesture: true) is a secondary
affordance. It watches for OverscrollNotifications carrying real drag details at the top of the
scroll extent — an actual finger/pointer drag, not a ballistic overscroll or mouse-wheel bounce. A
desktop user with a mouse or trackpad cannot produce this drag and is expected to use the
programmatic path instead — that is by design, not a gap.
It deliberately does not install or modify any ScrollPhysics or ScrollBehavior — a local
NotificationListener<ScrollNotification> is sufficient, and decorating every scrollable globally
was ruled out of scope for this widget. Set enableDragGesture: false to expose only the
programmatic path, e.g. on a surface where a drag-to-refresh gesture would conflict with another
gesture already installed on child.
v1 has no resistance/overscroll physics — dragProgress is a linear mapping of drag distance
to triggerDistance. An eased/resisted curve is a polish pass, not part of this v1.
idle → armed → refreshing → settling → idle
-
idle— no refresh in progress, indicator fully retracted and invisible. -
armed— the optional drag gesture crossed the trigger threshold but the pointer hasn't been released yet. Never reached by the programmaticrefresh()path — a programmatic trigger moves straight fromidletorefreshing. -
refreshing— the caller'sFutureis in flight. The indicator shows its loading spinner and announces itself to assistive technology. -
settling— theFutureresolved (or threw); the indicator animates back to retracted. Automatically transitions toidleonce the retraction completes.
| Parameter | Type | Notes |
|---|---|---|
onRefresh |
Future<void> Function() |
Required. Called whenever a refresh starts, by either entry point. Awaited before the indicator settles. |
child |
Widget |
Required. The scrollable content this indicator sits above. |
controller |
LayrzRefreshController? |
Optional. Null means the widget creates, owns, and disposes its own. Non-null means the caller owns disposal and must never swap the instance — asserted in didUpdateWidget, mirroring LayrzStepper. |
enableDragGesture |
bool |
Defaults to true. Set false to expose only the programmatic path. |
triggerDistance |
double |
Defaults to 80.0. How far (logical pixels) a drag must travel past the top before release commits to a refresh. Ignored when enableDragGesture is false. |
indicatorSize |
double |
Defaults to 32.0. Diameter of the loading visual. |
The controller is the source of truth; the widget is a thin renderer that subscribes via
addListener — the same contract LayrzStepperController established.
| Member | Notes |
|---|---|
state |
Current LayrzRefreshState. |
dragProgress |
How far an in-progress drag has advanced toward the trigger, [0.0, 1.0]. Stays 0.0 throughout a programmatic-only refresh. |
isRefreshing |
true for refreshing/settling, false for idle/armed. Useful for disabling a manual refresh button while one is already running. |
refresh(Future<void> Function() onRefresh) |
The primary public API. No-op if a refresh is already in progress. Rethrows any error from onRefresh after settling, so an awaiting caller still observes failure. |
settle() |
Called by the retraction animation on completion; not typically called by application code directly. |
updateDragProgress(double progress) |
Backing call for the optional gesture layer; ignored once a refresh has committed. |
releaseDrag(Future<void> Function() onRefresh) |
Backing call for the optional gesture layer's pointer-up. Commits to refresh if armed, otherwise resets dragProgress to 0.0. |
idle · armed · refreshing · settling — see the state machine above.
| Parameter | Type | Notes |
|---|---|---|
controller |
LayrzRefreshController |
Required. Drives updateDragProgress/releaseDrag as the user drags. |
onRefresh |
Future<void> Function() |
Required. Passed through to controller.releaseDrag. |
child |
Widget |
Required. Must contain a Scrollable descendant for overscroll notifications to be dispatched. |
triggerDistance |
double |
Defaults to 80.0. |
The loading visual LayrzRefreshIndicator paints — a ring that fills in proportion to
dragProgress while idle/armed, and a continuously rotating indeterminate arc while
refreshing/settling. Built independently of LayrzProgressBar — the two ship different shapes on
purpose (a ring here, a bar there) — so it owns its own CustomPainter rather than importing one.
| Parameter | Type | Notes |
|---|---|---|
state |
LayrzRefreshState |
Required. Drives which painting mode is used. |
dragProgress |
double |
Required. Only used pre-trigger; ignored once a refresh has committed. |
size |
double |
Defaults to 32.0. |
LayrzRefreshVisual sets liveRegion: true and announces 'Refreshing' while spinning, null
otherwise. Reduce-motion (MediaQuery.disableAnimationsOf) is checked on every build so an OS
setting change mid-refresh is honored immediately — the band-open/close animations jump straight
to their end values, and the spin controller does not tick, instead of merely deferring the check
to widget construction.
-
Colors:
sf3(ring track),primary(ring/arc indicator). -
Motion:
easingEnter(band expand),easingExit(band retract).
Component Catalog,
Milestone 6
Last updated: 2026-08-28 (first documentation of this widget)
Made with ❤️ by Golden M, Inc.
- LayrzAnchoredPanel
- LayrzBottomSheet
- LayrzDialog
- LayrzDropdownMenu
- LayrzResponsiveModal
- LayrzPageTransition
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput
- LayrzSlider
- LayrzStepper