drawer_rail 0.3.0
drawer_rail: ^0.3.0 copied to clipboard
A collapsible, themeable side navigation drawer with a full-width panel and a narrow icon rail: search, sections, badges, expandable groups and flyouts.
drawer_rail #
A collapsible, themeable side navigation drawer for Flutter.
DrawerRail has two states, both driven by your app's ColorScheme:
- an expanded panel — optional search, uppercase section labels, a selected pill, text/count badges and inline-expandable groups; and
- a narrow icon rail — where groups open as flyout menus.
State lives in a plain ChangeNotifier (DrawerRailController), so there is
no dependency on any state-management package.
Features #
- 🧩 Declarative content:
DrawerSection,DrawerLink,DrawerGroup. - ↔️ Animated collapse between a full panel and an icon rail.
- 🔎 Built-in, diacritic-insensitive search (
"acao"matches"Ação"). - 🏷️ Text and count badges (
New,4,99+). - 🎨 Themeable via
DrawerRailTheme, with automaticColorSchemefallbacks. - 🌍 Localizable chrome via
DrawerRailLabels. - 🧱 Custom header and footer slots (avatar, dark-mode toggle, sign out, …).
- 🧪 No runtime dependencies beyond Flutter.
Installation #
Add it to your pubspec.yaml:
dependencies:
drawer_rail: ^0.1.0
Then import it:
import 'package:drawer_rail/drawer_rail.dart';
Quick start #
Keep a DrawerRailController in your State, pass it and a list of entries to
DrawerRail, and place the drawer in a Row beside your content:
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _controller = DrawerRailController(selectedId: 'dashboard');
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
DrawerRail(
controller: _controller,
entries: [
const DrawerSection('Main'),
DrawerLink(
id: 'dashboard',
icon: Icons.dashboard_outlined,
label: 'Dashboard',
onTap: (context) => _open(context, const DashboardPage()),
),
DrawerLink(
id: 'payments',
icon: Icons.account_balance_wallet_outlined,
label: 'Payments',
badge: const DrawerBadge.count(4),
onTap: (context) => _open(context, const PaymentsPage()),
),
const DrawerSection('Workspace'),
DrawerGroup(
id: 'services',
icon: Icons.apps_rounded,
label: 'Services',
children: [
DrawerLink(
id: 'cloud',
icon: Icons.cloud_outlined,
label: 'Cloud Solutions',
onTap: (context) => _open(context, const CloudPage()),
),
],
),
],
),
const Expanded(child: Center(child: Text('Content'))),
],
),
);
}
}
DrawerRailrenders at a fixed width and animates it. Place it in aRow(or any layout that gives it an unbounded cross axis), not in theScaffold.drawerslot.
Entries #
| Type | Purpose |
|---|---|
DrawerSection |
A non-interactive header; a divider in the collapsed rail. |
DrawerLink |
A tappable destination with id, icon, label, onTap. |
DrawerGroup |
A group of links; expands inline, or opens a flyout collapsed. |
DrawerLink.onTap receives the drawer's BuildContext, so you can navigate
from it directly. Set danger: true for destructive items (e.g. sign out) to
tint them with the error color.
Badges #
DrawerBadge.text('New'); // pill with text
DrawerBadge.count(4); // numeric; hidden at 0, shown as "99+" above 99
The controller #
DrawerRailController is a ChangeNotifier:
final controller = DrawerRailController(
collapsed: false,
selectedId: 'dashboard',
initiallyExpanded: {'services'},
);
controller.toggleCollapsed(); // panel <-> rail
controller.setCollapsed(true);
controller.select('payments'); // highlight an entry
controller.toggleGroup('services');
Persisting state #
The controller does not persist anything itself. To remember, say, the collapse state, listen and save it wherever you like:
controller.addListener(() {
prefs.setBool('drawer_collapsed', controller.collapsed);
});
Theming — fully customizable #
Everything falls back to the ambient ColorScheme, so the drawer looks right
with zero configuration. When you want control, every size, spacing, color,
text style, icon and animation is exposed on DrawerRailTheme — override only
what you need:
DrawerRail(
controller: _controller,
entries: _entries,
theme: const DrawerRailTheme(
// Sizing & layout
expandedWidth: 320,
railWidth: 72,
borderRadius: 28,
itemBorderRadius: 12,
iconSize: 22,
railIconSize: 24,
railItemHeight: 48,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 6),
itemPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
groupChildIndent: 28,
position: DrawerRailPosition.left, // or .right
// Colors
selectedColor: Color(0xFF6366F1),
onSelectedColor: Colors.white,
iconColor: Color(0xFF334155),
labelColor: Color(0xFF0F172A),
sectionColor: Color(0xFF6366F1),
badgeCountColor: Color(0xFFEF4444),
// Hover feedback: shadow (default), highlight or none
hoverEffect: DrawerHoverEffect.shadow,
hoverShadowColor: Color(0x1F6366F1), // used in shadow mode
hoverHighlightColor: Color(0x146366F1), // used in highlight mode
// Activation: click (default) or hover, for web/desktop
railTrigger: DrawerActivationMode.hover, // pointer peeks the rail open
groupTrigger: DrawerActivationMode.hover, // pointer opens groups/flyouts
linkTrigger: DrawerActivationMode.click, // hover here also navigates
hoverOpenDelay: Duration(milliseconds: 120),
hoverCloseDelay: Duration(milliseconds: 220),
hoverSelectDelay: Duration(milliseconds: 300),
// Text styles (label color is applied automatically per state)
labelTextStyle: TextStyle(fontWeight: FontWeight.w600),
sectionTextStyle: TextStyle(fontWeight: FontWeight.w800, fontSize: 11),
badgeTextStyle: TextStyle(fontWeight: FontWeight.w800, fontSize: 11),
sectionUppercase: true,
// Icons for the built-in chrome
collapseIcon: Icons.chevron_left_rounded,
expandIcon: Icons.chevron_right_rounded,
searchIcon: Icons.search_rounded,
clearSearchIcon: Icons.close_rounded,
groupTrailingIcon: Icons.keyboard_arrow_down_rounded,
// Animation
animationDuration: Duration(milliseconds: 240),
animationCurve: Curves.easeOutCubic,
groupAnimationDuration: Duration(milliseconds: 200),
pressedScale: 0.97,
),
);
What each field controls #
| Group | Fields |
|---|---|
| Sizing | expandedWidth, railWidth, railItemHeight, iconSize, railIconSize, borderRadius, itemBorderRadius |
| Spacing | contentPadding, itemPadding, groupChildIndent |
| Colors | backgroundColor, selectedColor, onSelectedColor, iconColor, labelColor, sectionColor, badgeTextColor, badgeCountColor, menuBackgroundColor, searchFillColor, shadow |
| Hover | hoverEffect (shadow / highlight / none), hoverShadowColor, hoverHighlightColor |
| Activation | railTrigger, groupTrigger, linkTrigger (click / hover), hoverOpenDelay, hoverCloseDelay, hoverSelectDelay |
| Text | labelTextStyle, selectedLabelTextStyle, sectionTextStyle, badgeTextStyle, sectionUppercase |
| Icons | collapseIcon, expandIcon, searchIcon, clearSearchIcon, groupTrailingIcon |
| Motion | animationDuration, animationCurve, groupAnimationDuration, pressedScale |
| Layout | position (left / right) |
Hover activation (web & desktop) #
By default everything is driven by clicks. On pointer devices you can let the mouse do the work instead, per interaction:
DrawerRailTheme(
railTrigger: DrawerActivationMode.hover, // rail expands while hovered
groupTrigger: DrawerActivationMode.hover, // groups & flyouts open on hover
)
Three things worth knowing:
- Hover never replaces the click. Every item stays tappable in every mode, so touch users — where hover events simply never fire — are never locked out.
- A hover peek does not change
controller.collapsed. Hovering the rail widens the drawer temporarily; the pinned state you persist stays untouched. Readcontroller.railCollapsedwhen you need the width actually on screen, and keep persistingcollapsed. - A group you opened by clicking stays open when the pointer leaves. Only groups that hover itself opened are closed again.
linkTrigger: DrawerActivationMode.hover is the sharp one: resting on a link
for hoverSelectDelay runs its onTap, navigation included. On a dense menu
that can move the user somewhere they only meant to pass over. The dwell delay
makes it unlikely, not impossible — leave it on click unless you really want
pointer-driven navigation.
Widget-level customization #
Beyond the theme, DrawerRail itself exposes toggles and slots:
| Field | Purpose |
|---|---|
showSearch |
Show/hide the built-in search. |
showCollapseButton |
Show/hide the collapse/expand toggle. |
showFooterDivider |
Draw a divider above the footer. |
headerBuilder / footerBuilder |
Fully custom header/footer per state. |
searchDecoration |
Replace the search field's InputDecoration. |
labels |
Localize every built-in string (see below). |
Full control of the search field:
DrawerRail(
controller: _controller,
entries: _entries,
searchDecoration: InputDecoration(
hintText: 'Type to filter…',
prefixIcon: const Icon(Icons.filter_list_rounded),
filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(30)),
),
);
Header & footer #
Provide custom slots. Both builders receive the current collapsed state so
you can return a compact variant on the rail:
DrawerRail(
controller: _controller,
entries: _entries,
headerBuilder: (context, collapsed) =>
collapsed ? const _Logo() : const _LogoWithName(),
footerBuilder: (context, collapsed) => DarkModeToggle(collapsed: collapsed),
);
Localization #
DrawerRail(
controller: _controller,
entries: _entries,
labels: const DrawerRailLabels(
searchHint: 'Buscar...',
noResults: 'Nenhum resultado',
expandTooltip: 'Expandir',
collapseTooltip: 'Recolher',
),
);
Example #
A complete, runnable example lives in example/,
including a header logo, a dark-mode footer toggle, badges and a group.
Contributing #
Contributions are welcome from everyone! Fork the repo, create a branch, and open a pull request. Please read CONTRIBUTING.md first and follow the Code of Conduct.
Good first steps: open an issue for bugs or ideas, improve the docs, or add tests.
License #
MIT — see LICENSE.