Skip to content

LayrzRefreshIndicator

Kenny Mochizuki Escalona edited this page Aug 28, 2026 · 1 revision

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.


Overview: the programmatic API is primary, the drag gesture is optional

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()),
)

The drag gesture: optional, touch-only, no custom ScrollPhysics

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 physicsdragProgress is a linear mapping of drag distance to triggerDistance. An eased/resisted curve is a polish pass, not part of this v1.

State machine

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 programmatic refresh() path — a programmatic trigger moves straight from idle to refreshing.
  • refreshing — the caller's Future is in flight. The indicator shows its loading spinner and announces itself to assistive technology.
  • settling — the Future resolved (or threw); the indicator animates back to retracted. Automatically transitions to idle once the retraction completes.

API

LayrzRefreshIndicator

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.

LayrzRefreshController extends ChangeNotifier

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.

LayrzRefreshState (enum)

idle · armed · refreshing · settling — see the state machine above.

LayrzRefreshGestureDetector

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.

LayrzRefreshVisual

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.

Accessibility

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.

Design tokens used

  • Colors: sf3 (ring track), primary (ring/arc indicator).
  • Motion: easingEnter (band expand), easingExit (band retract).

Related documents

Component Catalog, Milestone 6


Last updated: 2026-08-28 (first documentation of this widget)

Clone this wiki locally