forked from mirrors/material_3_expressive
Refactor NavigationRailM3E to simplify rail types; update badge handling and expand functionality of navigation components.
This commit is contained in:
parent
83f5a02943
commit
5b27a91894
20 changed files with 360 additions and 486 deletions
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
library navigation_rail_m3e;
|
||||
|
||||
export 'src/type.dart';
|
||||
export 'src/modality.dart';
|
||||
export 'src/rail_theme.dart';
|
||||
export 'src/rail_tokens_adapter.dart';
|
||||
export 'src/navigation_rail_m3e_widget.dart';
|
||||
export 'src/rail_badge_m3e.dart';
|
||||
export 'src/rail_destination_m3e.dart';
|
||||
export 'src/rail_section_m3e.dart';
|
||||
export 'src/rail_fab_slot.dart';
|
||||
export 'src/navigation_rail_m3e_widget.dart';
|
||||
export 'src/rail_item_button_m3e.dart';
|
||||
export 'src/rail_section_m3e.dart';
|
||||
export 'src/rail_theme.dart';
|
||||
export 'src/rail_tokens_adapter.dart';
|
||||
export 'src/type.dart';
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class NavigationRailM3E extends StatefulWidget {
|
|||
this.expandedWidth,
|
||||
this.onDismissModal,
|
||||
this.onTypeChanged,
|
||||
this.labelBehavior = NavigationRailM3ELabelBehavior.alwaysShow,
|
||||
});
|
||||
|
||||
/// Presentation type for the rail (collapsed or expanded).
|
||||
|
|
@ -58,6 +59,9 @@ class NavigationRailM3E extends StatefulWidget {
|
|||
/// Called when the built-in menu button toggles the rail type.
|
||||
final ValueChanged<NavigationRailM3EType>? onTypeChanged;
|
||||
|
||||
/// Controls how labels are shown when the rail is expanded.
|
||||
final NavigationRailM3ELabelBehavior labelBehavior;
|
||||
|
||||
@override
|
||||
State<NavigationRailM3E> createState() => _NavigationRailM3EState();
|
||||
}
|
||||
|
|
@ -65,10 +69,15 @@ class NavigationRailM3E extends StatefulWidget {
|
|||
class _NavigationRailM3EState extends State<NavigationRailM3E>
|
||||
with TickerProviderStateMixin {
|
||||
OverlayEntry? _modalEntry;
|
||||
OverlayEntry? _collapsedPeekEntry;
|
||||
final LayerLink _anchor = LayerLink();
|
||||
bool _suppressInk = false;
|
||||
|
||||
bool get _isExpanded => widget.type == NavigationRailM3EType.expanded;
|
||||
bool get _isModal => widget.modality == NavigationRailM3EModality.modal;
|
||||
bool get _needsOverlay => _isModal;
|
||||
bool get _needsOverlay => _isModal && _isExpanded;
|
||||
bool get _needsCollapsedPeek =>
|
||||
!_isExpanded && !_isModal && widget.hideWhenCollapsed;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -79,17 +88,29 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
@override
|
||||
void didUpdateWidget(covariant NavigationRailM3E oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Suppress ink effects briefly during type transitions to avoid flicker.
|
||||
if (oldWidget.type != widget.type) {
|
||||
setState(() => _suppressInk = true);
|
||||
Future.delayed(const Duration(milliseconds: 320), () {
|
||||
if (mounted) setState(() => _suppressInk = false);
|
||||
});
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
_removeCollapsedPeekOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncOverlay() {
|
||||
if (!mounted) return;
|
||||
|
||||
// Expanded modal overlay management
|
||||
if (_needsOverlay) {
|
||||
if (_modalEntry == null) {
|
||||
_insertOverlay();
|
||||
|
|
@ -99,6 +120,17 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
} else {
|
||||
_removeOverlay();
|
||||
}
|
||||
|
||||
// Collapsed peek overlay management (standard modality with hideWhenCollapsed)
|
||||
if (_needsCollapsedPeek) {
|
||||
if (_collapsedPeekEntry == null) {
|
||||
_insertCollapsedPeekOverlay();
|
||||
} else {
|
||||
_collapsedPeekEntry!.markNeedsBuild();
|
||||
}
|
||||
} else {
|
||||
_removeCollapsedPeekOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
void _insertOverlay() {
|
||||
|
|
@ -113,6 +145,19 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
_modalEntry = null;
|
||||
}
|
||||
|
||||
void _insertCollapsedPeekOverlay() {
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
if (overlay == null) return;
|
||||
_collapsedPeekEntry =
|
||||
OverlayEntry(builder: (ctx) => _buildCollapsedPeekOverlay(ctx));
|
||||
overlay.insert(_collapsedPeekEntry!);
|
||||
}
|
||||
|
||||
void _removeCollapsedPeekOverlay() {
|
||||
_collapsedPeekEntry?.remove();
|
||||
_collapsedPeekEntry = null;
|
||||
}
|
||||
|
||||
Widget _buildModalOverlay(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
|
|
@ -143,6 +188,39 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildCollapsedPeekOverlay(BuildContext context) {
|
||||
// A small floating menu button anchored to the rail's target, visible when
|
||||
// the rail is fully hidden (hideWhenCollapsed == true).
|
||||
Widget btn = IconButtonM3E(
|
||||
icon: const Icon(Icons.menu),
|
||||
tooltip: 'Expand',
|
||||
onPressed: widget.onTypeChanged == null
|
||||
? null
|
||||
: () => widget.onTypeChanged!(NavigationRailM3EType.expanded),
|
||||
);
|
||||
if (_suppressInk) {
|
||||
final t = Theme.of(context);
|
||||
btn = Theme(
|
||||
data: t.copyWith(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
hoverColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
child: btn,
|
||||
);
|
||||
}
|
||||
|
||||
return CompositedTransformFollower(
|
||||
link: _anchor,
|
||||
showWhenUnlinked: false,
|
||||
offset: const Offset(8, 36), // slight inset and same top spacing as rail
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: btn,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _targetWidth(BuildContext context) {
|
||||
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
||||
const NavigationRailM3ETheme();
|
||||
|
|
@ -157,21 +235,35 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
Widget _buildMenuButton(BuildContext context,
|
||||
{required Alignment alignment}) {
|
||||
final isExpanded = _isExpanded;
|
||||
Widget button = IconButtonM3E(
|
||||
icon: Icon(isExpanded ? Icons.menu_open : Icons.menu),
|
||||
tooltip: isExpanded ? 'Collapse' : 'Expand',
|
||||
onPressed: widget.onTypeChanged == null
|
||||
? null
|
||||
: () => widget.onTypeChanged!(
|
||||
isExpanded
|
||||
? NavigationRailM3EType.collapsed
|
||||
: NavigationRailM3EType.expanded,
|
||||
),
|
||||
);
|
||||
|
||||
if (_suppressInk) {
|
||||
final t = Theme.of(context);
|
||||
button = Theme(
|
||||
data: t.copyWith(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
hoverColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12),
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: IconButtonM3E(
|
||||
icon: Icon(isExpanded ? Icons.menu_open : Icons.menu),
|
||||
tooltip: isExpanded ? 'Collapse' : 'Expand',
|
||||
onPressed: widget.onTypeChanged == null
|
||||
? null
|
||||
: () => widget.onTypeChanged!(
|
||||
isExpanded
|
||||
? NavigationRailM3EType.collapsed
|
||||
: NavigationRailM3EType.expanded,
|
||||
),
|
||||
),
|
||||
child: button,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -193,7 +285,7 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
size: fab.size,
|
||||
shapeFamily: FabM3EShapeFamily.square,
|
||||
density: fab.density,
|
||||
elevation: fab.elevation,
|
||||
elevation: 0,
|
||||
semanticLabel: fab.semanticLabel,
|
||||
)
|
||||
: FabM3E(
|
||||
|
|
@ -205,13 +297,14 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
size: fab.size,
|
||||
shapeFamily: FabM3EShapeFamily.square,
|
||||
density: fab.density,
|
||||
elevation: fab.elevation,
|
||||
elevation: 0,
|
||||
semanticLabel: fab.semanticLabel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildChildren(BuildContext context) {
|
||||
List<Widget> _buildChildren(BuildContext context,
|
||||
{required bool showLabels}) {
|
||||
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
||||
const NavigationRailM3ETheme();
|
||||
final isExpanded = _isExpanded;
|
||||
|
|
@ -250,6 +343,8 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
selected: index == widget.selectedIndex,
|
||||
onTap: () => widget.onDestinationSelected(index),
|
||||
expanded: true,
|
||||
labelBehavior: widget.labelBehavior,
|
||||
suppressInk: _suppressInk,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
|
@ -265,6 +360,8 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
selected: i == widget.selectedIndex,
|
||||
onTap: () => widget.onDestinationSelected(i),
|
||||
expanded: false,
|
||||
labelBehavior: widget.labelBehavior,
|
||||
suppressInk: _suppressInk,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
|
@ -280,9 +377,14 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
curve: Curves.easeOutCubic,
|
||||
width: width,
|
||||
decoration: BoxDecoration(color: tokens.containerColor),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: _buildChildren(context),
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final showLabels = _isExpanded && constraints.maxWidth >= 180;
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: _buildChildren(ctx, showLabels: showLabels),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -292,13 +394,11 @@ class _NavigationRailM3EState extends State<NavigationRailM3E>
|
|||
// Keep overlay in sync after build completes to avoid layout side-effects.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOverlay());
|
||||
|
||||
if (_needsOverlay) {
|
||||
// When showing modal via overlay, render nothing in the layout slot so
|
||||
// content underneath can occupy the width. The overlay covers it.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final Widget child =
|
||||
_needsOverlay ? const SizedBox.shrink() : _buildRailCore(context);
|
||||
|
||||
return _buildRailCore(context);
|
||||
// Always provide an anchor target for positioning collapsed peek overlay.
|
||||
return CompositedTransformTarget(link: _anchor, child: child);
|
||||
}
|
||||
|
||||
static int _destinationIndex(List<NavigationRailM3ESection> sections,
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
import 'dart:ui' show FontFeature;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'rail_tokens_adapter.dart';
|
||||
|
||||
/// Large numeric badge for rail items (0..999+). One class per file.
|
||||
/// Large numeric badgeValue for rail items (0..999+). One class per file.
|
||||
class RailBadgeM3E extends StatelessWidget {
|
||||
/// Creates a large numeric badge.
|
||||
/// Creates a large numeric badgeValue.
|
||||
const RailBadgeM3E({
|
||||
super.key,
|
||||
required this.count,
|
||||
this.count,
|
||||
this.maxDigits = 3,
|
||||
this.dense = false,
|
||||
});
|
||||
|
||||
/// The numeric value to display in the badge.
|
||||
final int count;
|
||||
/// The numeric value to display in the badgeValue.
|
||||
final int? count;
|
||||
|
||||
/// Maximum digits before showing a trailing '+' (e.g. 999+).
|
||||
final int maxDigits;
|
||||
|
|
@ -24,25 +23,37 @@ class RailBadgeM3E extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (count == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final tokens = NavigationRailTokensAdapter(context);
|
||||
final String text = count > (10 * (pow10(maxDigits) - 1))
|
||||
final String text = count! > (10 * (pow10(maxDigits) - 1))
|
||||
? '${pow10(maxDigits) - 1}+'
|
||||
: '$count';
|
||||
final double pad = dense ? 2 : 4;
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.badgeLargeBackground,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.labelSmall!.copyWith(
|
||||
color: tokens.badgeLargeLabel,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
child: Text(text, maxLines: 1),
|
||||
),
|
||||
);
|
||||
return count == 0
|
||||
? Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.badgeBackground,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: pad + 2, vertical: pad),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.badgeBackground,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.labelSmall!.copyWith(
|
||||
color: tokens.badgeLargeLabel,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
child: Text(text, maxLines: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns 10 to the power of [n].
|
||||
|
|
@ -54,3 +65,19 @@ class RailBadgeM3E extends StatelessWidget {
|
|||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallDot extends StatelessWidget {
|
||||
const _SmallDot({required this.color});
|
||||
final Color color;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
import 'package:fab_m3e/fab_m3e.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:icon_button_m3e/icon_button_m3e.dart';
|
||||
|
||||
import 'rail_fab_slot.dart';
|
||||
import 'rail_item.dart';
|
||||
import 'rail_section_m3e.dart';
|
||||
import 'rail_theme.dart';
|
||||
import 'rail_tokens_adapter.dart';
|
||||
|
||||
/// Collapsed (96dp) rail. One class per file.
|
||||
class CollapsedRail extends StatelessWidget {
|
||||
const CollapsedRail({
|
||||
super.key,
|
||||
required this.sections,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
this.fab,
|
||||
this.hideWhenCollapsed = false,
|
||||
required this.isExpanded,
|
||||
this.onToggleType,
|
||||
});
|
||||
|
||||
/// Sections rendered in the rail.
|
||||
final List<NavigationRailM3ESection> sections;
|
||||
|
||||
/// Currently selected destination index.
|
||||
final int selectedIndex;
|
||||
|
||||
/// Callback when a destination is tapped.
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
|
||||
/// Whether the current rail type is expanded.
|
||||
final bool isExpanded;
|
||||
|
||||
/// Called when the user taps the built-in menu button to toggle type.
|
||||
final VoidCallback? onToggleType;
|
||||
|
||||
/// Optional FAB/extended FAB slot.
|
||||
final NavigationRailM3EFabSlot? fab;
|
||||
|
||||
/// When true and rail is collapsed, animate width to zero.
|
||||
final bool hideWhenCollapsed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
||||
const NavigationRailM3ETheme();
|
||||
final tokens = NavigationRailTokensAdapter(context);
|
||||
|
||||
final width = hideWhenCollapsed ? 0.0 : theme.collapsedWidth;
|
||||
final allDestinations = sections.expand((s) => s.destinations).toList();
|
||||
|
||||
final Widget content = ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
const SizedBox(height: 36),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(
|
||||
start: 16.0, end: 16.0, bottom: 12.0),
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: IconButtonM3E(
|
||||
icon: Icon(isExpanded ? Icons.menu_open : Icons.menu),
|
||||
tooltip: isExpanded ? 'Collapse' : 'Expand',
|
||||
onPressed: onToggleType,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (fab != null)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(
|
||||
start: 16.0, end: 16.0, bottom: 12.0),
|
||||
child: FabM3E(
|
||||
icon: fab!.icon,
|
||||
onPressed: fab!.onPressed,
|
||||
tooltip: fab!.tooltip,
|
||||
heroTag: fab!.heroTag,
|
||||
kind: fab!.kind,
|
||||
size: fab!.size,
|
||||
shapeFamily: FabM3EShapeFamily.square,
|
||||
density: fab!.density,
|
||||
elevation: fab!.elevation,
|
||||
semanticLabel: fab!.semanticLabel,
|
||||
),
|
||||
),
|
||||
for (int i = 0; i < allDestinations.length; i++) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(
|
||||
start: 16.0, end: 16.0, top: 8.0, bottom: 8.0),
|
||||
child: RailItem(
|
||||
destination: allDestinations[i],
|
||||
selected: i == selectedIndex,
|
||||
onTap: () => onDestinationSelected(i),
|
||||
expanded: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
width: width,
|
||||
decoration: BoxDecoration(color: tokens.containerColor),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,7 @@ class NavigationRailM3EDestination {
|
|||
required this.icon,
|
||||
this.selectedIcon,
|
||||
required this.label,
|
||||
this.largeBadgeCount,
|
||||
this.smallBadge = false,
|
||||
this.badgeCount,
|
||||
this.semanticLabel,
|
||||
this.short = false,
|
||||
});
|
||||
|
|
@ -15,8 +14,7 @@ class NavigationRailM3EDestination {
|
|||
final Widget icon;
|
||||
final Widget? selectedIcon;
|
||||
final String label;
|
||||
final int? largeBadgeCount;
|
||||
final bool smallBadge;
|
||||
final int? badgeCount;
|
||||
final String? semanticLabel;
|
||||
|
||||
/// If true, uses short item height (56dp) instead of 64dp.
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
import 'package:fab_m3e/fab_m3e.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:icon_button_m3e/icon_button_m3e.dart';
|
||||
|
||||
import 'rail_destination_m3e.dart';
|
||||
import 'rail_fab_slot.dart';
|
||||
import 'rail_item.dart';
|
||||
import 'rail_section_m3e.dart';
|
||||
import 'rail_theme.dart';
|
||||
import 'rail_tokens_adapter.dart';
|
||||
|
||||
/// Expanded rail (220–360dp). One class per file.
|
||||
class ExpandedRail extends StatelessWidget {
|
||||
/// Creates the expanded rail variant.
|
||||
const ExpandedRail({
|
||||
super.key,
|
||||
required this.sections,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
required this.isExpanded,
|
||||
this.onToggleType,
|
||||
this.fab,
|
||||
this.width,
|
||||
this.modal = false,
|
||||
this.onDismissModal,
|
||||
});
|
||||
|
||||
/// Sections rendered in the rail.
|
||||
final List<NavigationRailM3ESection> sections;
|
||||
|
||||
/// Currently selected destination index.
|
||||
final int selectedIndex;
|
||||
|
||||
/// Callback when a destination is tapped.
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
|
||||
/// Whether the current rail type is expanded.
|
||||
final bool isExpanded;
|
||||
|
||||
/// Called when the user taps the built-in menu button to toggle type.
|
||||
final VoidCallback? onToggleType;
|
||||
|
||||
/// Optional FAB/extended FAB slot.
|
||||
final NavigationRailM3EFabSlot? fab;
|
||||
|
||||
/// Desired rail width (clamped to theme min/max) when expanded.
|
||||
final double? width;
|
||||
|
||||
/// Whether the expanded rail is displayed as a modal overlay.
|
||||
final bool modal;
|
||||
|
||||
/// Invoked to dismiss when [modal] is true.
|
||||
final VoidCallback? onDismissModal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
||||
const NavigationRailM3ETheme();
|
||||
final tokens = NavigationRailTokensAdapter(context);
|
||||
|
||||
final w = (width ?? theme.expandedMinWidth)
|
||||
.clamp(theme.expandedMinWidth, theme.expandedMaxWidth);
|
||||
|
||||
final children = <Widget>[
|
||||
const SizedBox(height: 36),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButtonM3E(
|
||||
icon: Icon(isExpanded ? Icons.menu_open : Icons.menu),
|
||||
tooltip: isExpanded ? 'Collapse' : 'Expand',
|
||||
onPressed: onToggleType,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (fab != null)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 12),
|
||||
child: ExtendedFabM3E(
|
||||
label: Text(fab!.label),
|
||||
icon: fab!.icon,
|
||||
onPressed: fab!.onPressed,
|
||||
tooltip: fab!.tooltip,
|
||||
heroTag: fab!.heroTag,
|
||||
kind: fab!.kind,
|
||||
size: fab!.size,
|
||||
shapeFamily: FabM3EShapeFamily.square,
|
||||
density: fab!.density,
|
||||
elevation: fab!.elevation,
|
||||
semanticLabel: fab!.semanticLabel,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
for (final section in sections) {
|
||||
if (section.header != null) {
|
||||
children.add(Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: 16,
|
||||
end: 16,
|
||||
top: theme.sectionHeaderSpacingTop,
|
||||
bottom: theme.sectionHeaderSpacingBottom,
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.titleSmall!.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
child: section.header!,
|
||||
),
|
||||
));
|
||||
}
|
||||
for (final dest in section.destinations) {
|
||||
final index = _destinationIndex(sections, dest);
|
||||
children.add(Padding(
|
||||
padding: const EdgeInsetsDirectional.only(
|
||||
start: 16, end: 16, top: 8.0, bottom: 8.0),
|
||||
child: RailItem(
|
||||
destination: dest,
|
||||
selected: index == selectedIndex,
|
||||
onTap: () => onDestinationSelected(index),
|
||||
expanded: true,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final rail = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
width: w.toDouble(),
|
||||
decoration: BoxDecoration(color: tokens.containerColor),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
|
||||
if (!modal) return rail;
|
||||
|
||||
// Modal overlay with scrim
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: onDismissModal,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.scrim
|
||||
.withValues(alpha: 0.32)),
|
||||
),
|
||||
),
|
||||
Align(alignment: Alignment.centerLeft, child: rail),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static int _destinationIndex(List<NavigationRailM3ESection> sections,
|
||||
NavigationRailM3EDestination dest) {
|
||||
var i = 0;
|
||||
for (final s in sections) {
|
||||
for (final d in s.destinations) {
|
||||
if (identical(d, dest)) return i;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'rail_badge_m3e.dart';
|
||||
import 'rail_destination_m3e.dart';
|
||||
import 'rail_theme.dart';
|
||||
import 'rail_tokens_adapter.dart';
|
||||
import 'package:navigation_rail_m3e/navigation_rail_m3e.dart';
|
||||
|
||||
/// Single rail item (private to package). One class per file.
|
||||
class RailItem extends StatelessWidget {
|
||||
|
|
@ -14,17 +10,28 @@ class RailItem extends StatelessWidget {
|
|||
required this.selected,
|
||||
required this.onTap,
|
||||
required this.expanded,
|
||||
required this.labelBehavior,
|
||||
this.suppressInk = false,
|
||||
});
|
||||
|
||||
/// Destination data driving this item.
|
||||
final NavigationRailM3EDestination destination;
|
||||
|
||||
/// Whether this item is currently selected.
|
||||
final bool selected;
|
||||
|
||||
/// Called when the item is tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Whether the rail is expanded (shows label and badges inline).
|
||||
final bool expanded;
|
||||
|
||||
/// Whether this item's label should be visible.
|
||||
final NavigationRailM3ELabelBehavior labelBehavior;
|
||||
|
||||
/// When true, disables splash/hover/highlight effects to prevent flicker during transitions.
|
||||
final bool suppressInk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context).extension<NavigationRailM3ETheme>() ??
|
||||
|
|
@ -32,95 +39,41 @@ class RailItem extends StatelessWidget {
|
|||
final tokens = NavigationRailTokensAdapter(context);
|
||||
final height = destination.short ? theme.itemShortHeight : theme.itemHeight;
|
||||
|
||||
final icon = IconTheme.merge(
|
||||
data: IconThemeData(
|
||||
size: theme.iconSize,
|
||||
color:
|
||||
selected ? tokens.activeIconAndLabel : tokens.inactiveIconAndLabel,
|
||||
),
|
||||
child: selected
|
||||
? (destination.selectedIcon ?? destination.icon)
|
||||
: destination.icon,
|
||||
final Widget button = RailItemButtonM3E(
|
||||
icon: destination.icon,
|
||||
selectedIcon: destination.selectedIcon,
|
||||
isSelected: selected,
|
||||
onPressed: onTap,
|
||||
expanded: expanded,
|
||||
labelBehavior: labelBehavior,
|
||||
label: destination.label,
|
||||
semanticLabel: destination.semanticLabel,
|
||||
suppressInk: suppressInk,
|
||||
badgeCount: destination.badgeCount,
|
||||
);
|
||||
|
||||
final label = DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.labelLarge!.copyWith(
|
||||
color: selected
|
||||
? tokens.activeIconAndLabel
|
||||
: tokens.inactiveIconAndLabel,
|
||||
),
|
||||
child: Text(
|
||||
destination.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
semanticsLabel: destination.semanticLabel ?? destination.label,
|
||||
),
|
||||
);
|
||||
Widget core;
|
||||
if (!expanded) {
|
||||
// Collapsed: left-aligned icon-only button with 48x48 tap target.
|
||||
core = SizedBox(
|
||||
height: height,
|
||||
child: Align(alignment: Alignment.centerLeft, child: button),
|
||||
);
|
||||
} else {
|
||||
core = ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: height),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: button),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final badges = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (destination.largeBadgeCount != null &&
|
||||
destination.largeBadgeCount! > 0)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: theme.iconLabelGap),
|
||||
child: RailBadgeM3E(count: destination.largeBadgeCount!),
|
||||
),
|
||||
if (destination.smallBadge)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 6.0),
|
||||
child: _SmallDot(color: tokens.badgeSmallDot),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final content = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
height: height,
|
||||
decoration: ShapeDecoration(
|
||||
color: selected ? tokens.activeIndicatorColor : Colors.transparent,
|
||||
shape: const StadiumBorder(), // Full corner per spec
|
||||
),
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: theme.indicatorLeading,
|
||||
end: theme.indicatorTrailing,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
SizedBox(width: theme.iconLabelGap),
|
||||
if (expanded) Expanded(child: label) else const SizedBox.shrink(),
|
||||
if (expanded) badges,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Full-width hit target
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallDot extends StatelessWidget {
|
||||
const _SmallDot({required this.color});
|
||||
final Color color;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: core,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class NavigationRailTokensAdapter {
|
|||
_cs.onSecondaryContainer;
|
||||
}
|
||||
|
||||
Color get badgeLargeBackground =>
|
||||
Color get badgeBackground =>
|
||||
_maybe(() => context.m3e.colors.error) ?? _cs.error;
|
||||
Color get badgeLargeLabel =>
|
||||
_maybe(() => context.m3e.colors.onError) ?? _cs.onError;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// M3 Expressive types for the rail.
|
||||
enum NavigationRailM3EType {
|
||||
/// Slim 96dp rail.
|
||||
|
|
@ -12,4 +10,15 @@ enum NavigationRailM3EType {
|
|||
extension NavigationRailM3ETypeX on NavigationRailM3EType {
|
||||
bool get isCollapsed => this == NavigationRailM3EType.collapsed;
|
||||
bool get isExpanded => this == NavigationRailM3EType.expanded;
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls how labels are shown for rail destinations when the rail is expanded.
|
||||
///
|
||||
/// - alwaysShow (default): show all labels (subject to width constraints).
|
||||
/// - onlySelected: show the label only for the selected destination.
|
||||
/// - alwaysHide: never show labels even when expanded.
|
||||
enum NavigationRailM3ELabelBehavior {
|
||||
alwaysShow,
|
||||
onlySelected,
|
||||
alwaysHide,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue