flutter_tray 0.0.2
flutter_tray: ^0.0.2 copied to clipboard
An opinionated Flutter tray with fluid, content-sized page transitions.
flutter_tray #
Flutter Tray is an opinionated bottom-sheet flow for Flutter. Give it a widget. When that widget changes, the tray changes with it—content, height, action, and all.
There are already plenty of bottom sheet packages. Here comes another one. This one is for compact flows that should feel connected instead of becoming a pile of modal pages.
Install #
flutter pub add flutter_tray
Usage #
import 'package:flutter_tray/flutter_tray.dart';
await showFlutterTray<void>(
context: context,
presentation: FlutterTrayPresentation.floating,
page: FlutterTrayPageSpec(
builder: (_) => const FlutterTrayPage(
title: Text('Choose a vehicle'),
child: VehiclePicker(),
),
primaryActionBuilder: (trayContext) => MyBrandButton(
label: 'Continue',
onPressed: () => Navigator.of(trayContext).pop(),
),
),
bottomActionWrapperBuilder: (_, action) =>
FlutterTrayActionBar(child: action),
);
MyBrandButton can be anything. Material, Cupertino, a painted gesture
surface, your design system's primary control—Flutter Tray only asks for a
widget.
Features #
- Content-sized transitions – The tray grows and shrinks with its child
- Tray pages – Push keyed pages without mounting another
Navigator - Stationary actions – Change the page while its primary action stays put
- Your buttons – Use arbitrary widgets for one action or an entire action bar
- Floating mode – Fully rounded, inset, keyboard-aware presentation
- Detents – Content, pixel, and viewport offsets with snap grids
- Dismissal policy – Gate barrier, swipe, and back dismissal attempts
- Keyboard policy – Dismiss on upward, downward, or any tray drag
- Scaffold slots – Optional top bar, body, and page-local bottom bar
- Reduced motion – Respects
MediaQuery.disableAnimations - No runtime dependencies – Just Flutter
Gradual revelation #
Full-screen navigation is useful when someone is going somewhere new. A tray is useful when they are still doing the thing in front of them.
Flutter Tray keeps that context visible. A confirmation can unfold above the screen that created it. A warning can become a choice. A short choice can become a form. Each step appears inside the same physical surface, so the user can see the flow changing instead of feeling transported somewhere else.
The height change is part of the navigation. A taller next page makes progress visible. A smaller page brings the flow back down. Content, action, and shape follow a few shared rules, which is what lets a multi-step tray feel like one continuous object.
The default structure is deliberately small:
- one focused page at a time;
- one optional stationary primary action;
- one keyed transition between page states;
- one surface that adapts to its content.
Those are opinions, not restrictions. Place any widget inside when the ready-made page is useful. Own the entire child when it is not.
Change pages #
Every page can own its content, motion, action, and action visibility.
FlutterTray.of(context).pushPage(
FlutterTrayPageSpec(
key: const ValueKey('confirmation'),
builder: (_) => const FlutterTrayPage(
title: Text('Confirm transfer'),
child: TransferSummary(),
),
primaryActionBuilder: (context) => TransferButton(
onPressed: () => confirmTransfer(),
),
actionVisibility: const FlutterTrayActionVisibility.keyboardHidden(),
),
);
Use FlutterTray.of(context).pop() to return to the previous page. The outgoing
content, incoming content, height, and stationary action use the same transition
system.
Bring your own layout #
The package does not need to own the title, spacing, or button.
await showFlutterTray<void>(
context: context,
presentation: FlutterTrayPresentation.floating,
builder: (_) => const MyEntireTray(),
);
FlutterTrayPage, FlutterTrayActionBar, and FlutterTrayScaffold are
conveniences. The animation works without them.
Floating trays #
Floating mode is fully rounded and inset by default.
await showFlutterTray<void>(
context: context,
presentation: FlutterTrayPresentation.floating,
floatingMargin: const EdgeInsets.all(16),
floatingKeyboardMargin: const EdgeInsets.fromLTRB(16, 0, 16, 32),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(36),
),
builder: (_) => const MyTrayContent(),
);
Surface color, elevation, clip behavior, margins, shape, and drag handle are all replaceable independently.
Detents #
Use typed detents when the tray should also be user-resizable.
final controller = FlutterTrayController();
await showFlutterTray<void>(
context: context,
controller: controller,
presentation: FlutterTrayPresentation.floating,
detents: const FlutterTrayDetentConfiguration(
initialDetent: FlutterTrayDetent.content(),
snapGrid: FlutterTraySnapGrid.detents(
detents: [
FlutterTrayDetent.pixels(240),
FlutterTrayDetent.viewport(fraction: 0.75),
],
),
),
builder: (_) => const ResultsList(),
);
The floating handle owns detent dragging, so scrollable content keeps its own
gesture space. Use animateToDetent or jumpToDetent for programmatic control.
FlutterTraySnapGrid.stepless keeps the final dragged height instead of snapping
to a fixed list.
Dismissal and keyboard #
await showFlutterTray<void>(
context: context,
dismissPolicy: FlutterTrayDismissPolicy(
onDismissAttempt: (context) async => confirmDiscard(context),
),
keyboardDismissBehavior:
const FlutterTrayKeyboardDismissBehavior.onDragDown(
isContentScrollAware: true,
),
builder: (_) => const EditProfileForm(),
);
Dismissal attempts can be allowed synchronously or asynchronously. Use
FlutterTrayDismissPolicy.locked() for a tray that must close through an
explicit app-owned action.
Requirements #
- Dart 3.12.2+
- Flutter 3.44.6+
Acknowledgements #
Flutter Tray's language of trays and gradual revelation was inspired by Family's dynamic tray system.
Thanks to smooth_sheets for its
thoughtful work on Flutter sheet primitives. Flutter Tray's detents, snap grids,
dismissal policy, and keyboard behavior are original implementations informed
by those ideas.
License #
© 2026 Eric Noel
Licensed under MIT