material_async_button
Drop-in async wrappers for Flutter Material buttons
(package:material_ui/material_ui.dart). Adds a loading state to
ElevatedButton, FilledButton, OutlinedButton, TextButton, IconButton,
and FloatingActionButton — without forcing you to build a project-wide wrapper
widget.
ElevatedAsyncButton(
onPressed: api.save,
child: const Text('Save'),
)
That's it. The button shows a spinner while save() runs and returns to its
label when it completes.
Install
flutter pub add material_async_button
Note: Version 4.x targets
package:material_ui/material_ui.dart. Your app must also importpackage:material_ui/material_ui.dart(migrate withdart fix --apply --code=migrate_design_widgets); withpackage:flutter/material.dart, the buttons ignore your app'sThemeData.
Why
Most apps end up writing their own DefaultAsyncButton wrapper to share a
loading widget and its transition across screens. This package gives you that
wrapper as a ThemeExtension — configure once, every *AsyncButton picks
it up; override per button when you need to.
Material wrappers
| Material | Async counterpart | Variants |
|---|---|---|
ElevatedButton |
ElevatedAsyncButton |
.icon |
FilledButton |
FilledAsyncButton |
.tonal, .icon, .tonalIcon |
OutlinedButton |
OutlinedAsyncButton |
.icon |
TextButton |
TextAsyncButton |
.icon |
IconButton |
IconAsyncButton |
.filled, .filledTonal, .outlined |
FloatingActionButton |
FloatingActionAsyncButton |
.small, .large, .extended |
Every Material constructor is mirrored. All Material parameters (style,
focusNode, autofocus, clipBehavior, statesController, etc.) are
forwarded verbatim, with two FAB exceptions: onPressed is required, and the
default heroTag is a package sentinel rather than Flutter's, so no hero flight
runs between a plain and an async FAB. AsyncButtonTheme complements
ButtonStyle / ButtonThemeData — it carries only async behaviour, never
styling.
Loading only — by design
The button does one job: show a spinner while onPressed is in flight. It has
no success or error state.
-
No error state. An in-button error view is a Material anti-pattern, and error handling belongs to your state management. When
onPressedthrows, the button returns to idle and re-throws: a tap's error reaches the surrounding zone (yourrunZonedGuarded, elsePlatformDispatcher.instance.onError) like any other uncaught async error, and acontroller.trigger()caller gets a rejected Future. Handle it where it belongs:// Typical: your notifier/repository absorbs the failure internally // (e.g. AsyncValue.guard), so onPressed never throws. ElevatedAsyncButton( onPressed: () => ref.read(saveProvider.notifier).save(), child: const Text('Save'), ) // Or handle it inline and surface it your way: ElevatedAsyncButton( onPressed: () async { try { await repo.submit(); } on Exception catch (error) { messenger.showSnackBar(SnackBar(content: Text('$error'))); } }, child: const Text('Submit'), ) -
No success state. Success is handled by what your action already does — navigate away, flip the label (Save → Unsave), update a list. An in-button "Saved ✓" is usually redundant. Surface it the same way you surface any state change.
Theming
AsyncButtonTheme is a ThemeExtension. Resolution order for any field is
per-widget value → theme value → built-in fallback.
Widget thickSpinner(BuildContext _) => const AsyncButtonSpinner(strokeWidth: 3);
ThemeData(
extensions: [
AsyncButtonTheme(
loadingBuilder: thickSpinner,
// transitionBuilder: animate every button's swap — see Defaults below.
maintainSize: true, // keep the idle footprint while loading
minLoadingDuration: const Duration(milliseconds: 300), // anti-flicker
),
],
)
Pass loadingBuilder and transitionBuilder as top-level or static
functions, not inline closures. Two closures are never equal, so a
ThemeData built in build() with an inline closure differs on every
rebuild, and MaterialApp restarts its 200 ms theme animation each time.
Per-widget builders aren't affected.
maintainSize(defaultfalse) overlays the loading view on the invisible idle child, so the button keeps its idle size — no width jump..iconconstructors keep their icon visible beside the spinner.minLoadingDuration(defaultDuration.zero) holds the loading view at least that long from the tap orcontroller.trigger(), so a fast future doesn't flash a spinner. An error rethrows once the floor has elapsed.
Both are also per-widget parameters on every button and on AsyncButton.
With no extension registered, AsyncButtonTheme.of falls back to
AsyncButtonTheme.empty — the default spinner and nothing else.
copyWith can't reset a field to null — build a new AsyncButtonTheme
instead.
Custom buttons — AsyncButton
AsyncButton is the low-level escape hatch. Use it when none of the Material
wrappers fit. The builder receives whether the button is loading:
AsyncButton(
onPressed: doWork,
child: const Text('Go'),
builder: (context, child, callback, isLoading) => MyButton(
onTap: callback,
color: isLoading ? Colors.grey : Colors.indigo,
child: child,
),
)
External control
AsyncButtonController is a ValueListenable<bool> (loading) plus imperative
methods. Use it for form keyboard "Done", parent-owned state, cross-widget
reactions, and tests.
final controller = AsyncButtonController(); // dispose like any ChangeNotifier
TextField(
textInputAction: .done,
onSubmitted: (_) => controller.trigger(),
)
ElevatedAsyncButton(
controller: controller,
onPressed: submit,
child: const Text('Submit'),
)
controller.trigger(); // run onPressed from outside (rethrows on failure);
// while busy, returns the in-flight run's Future
controller.reset(); // force back to idle, abandoning the in-flight run
controller.value; // bool — true while loading (ValueListenable<bool>)
controller.canTrigger; // bool — true when trigger() would run (not loading, bound to an enabled button)
reset() abandons the in-flight run: the button is idle and re-armed at once
(an escape hatch for a hung future), and the abandoned run's completion is
ignored. One controller drives one mounted button — binding it to a second
throws a StateError once the frame settles. trigger() throws a
StateError when no button is bound (never passed to one, or its button
unmounted or switched to another controller); gate on canTrigger. While a
run is in flight, trigger() starts nothing and returns that run's Future, so
await controller.trigger() waits for the real run and sees its error.
trigger() and reset() throw a StateError after dispose().
A run belongs to the controller that started it: unmounting the button or
switching it to another controller leaves the run, and its
minLoadingDuration, on that controller until it settles, or until you
reset() or dispose() it.
Defaults
| State | UI |
|---|---|
| idle | your child |
| loading | AsyncButtonSpinner (sized to the label) |
The label-button .icon constructors (ElevatedAsyncButton.icon,
FilledAsyncButton.icon, etc.) drop the icon while loading and show the spinner
alone, unless maintainSize is set — then the icon stays. (IconAsyncButton
has no .icon variant — it swaps its sole icon for the spinner.)
Loading never disables the button. Being loading and being disabled are
different things — the spinner is the indicator, the button keeps its themed
enabled colours, and taps that can't run are silently swallowed (onLongPress
is gated off while busy). The button shows the disabled look only when you
disable it explicitly — pass enabled: false (defaults to true) or
onPressed: null. As in Flutter, a label button with onPressed: null and an
onLongPress stays enabled for long-press; IconAsyncButton, like
IconButton, ignores onLongPress without onPressed. Either path also
no-ops an external controller.trigger().
The swap is instant; the button resizes to fit the loading widget. To keep
the idle size instead, set maintainSize: true. The button does no animation of
its own. To smooth the swap — and the size change when the spinner differs from
the child — pass a transitionBuilder. The child is already keyed by loading
state, so an AnimatedSwitcher inside an AnimatedSize is all it takes:
ElevatedAsyncButton(
onPressed: api.save,
transitionBuilder: (context, child, isLoading) => AnimatedSize(
duration: const Duration(milliseconds: 200),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: child,
),
),
child: const Text('Save'),
)
Set transitionBuilder on AsyncButtonTheme to animate every button at once.
AsyncButtonSpinner is public and inherits the button's foreground — customise
its color / strokeWidth / size and return it from loadingBuilder.
Agent skill
This package ships an agent skill in skills/material-async-button-usage/. Install it into your project's agent config with:
dart run skills@ get --package material_async_button --all
Contributing
See CONTRIBUTING.md.
License
MIT — see LICENSE.
Libraries
- Drop-in async wrappers for Flutter Material buttons.