blob: 611a2d51d7644abdb43fa99429e31f1a56df1cd6 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
import 'color_scheme.dart';
import 'colors.dart';
import 'drawer.dart';
import 'ink_well.dart';
import 'material.dart';
import 'material_localizations.dart';
import 'material_state.dart';
import 'navigation_bar.dart';
import 'navigation_drawer_theme.dart';
import 'text_theme.dart';
import 'theme.dart';
/// Material Design Navigation Drawer component.
///
/// On top of [Drawer]s, Navigation drawers offer a persistent and convenient way to switch
/// between primary destinations in an app.
///
/// The style for the icons and text are not affected by parent
/// [DefaultTextStyle]s or [IconTheme]s but rather controlled by parameters or
/// the [NavigationDrawerThemeData].
///
/// The [children] are a list of widgets to be displayed in the drawer. These can be a
/// mixture of any widgets, but there is special handling for [NavigationDrawerDestination]s.
/// They are treated as a group and when one is selected, the [onDestinationSelected]
/// is called with the index into the group that corresponds to the selected destination.
///
/// {@tool dartpad}
/// This example shows a [NavigationDrawer] used within a [Scaffold]
/// widget. The [NavigationDrawer] has headline widget, divider widget and three
/// [NavigationDrawerDestination] widgets. The initial [selectedIndex] is 0.
/// The [onDestinationSelected] callback changes the selected item's index and displays
/// a corresponding widget in the body of the [Scaffold].
///
/// ** See code in examples/api/lib/material/navigation_drawer/navigation_drawer.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [Scaffold.drawer], where one specifies a [Drawer] so that it can be
/// shown.
/// * [Scaffold.of], to obtain the current [ScaffoldState], which manages the
/// display and animation of the drawer.
/// * [ScaffoldState.openDrawer], which displays its [Drawer], if any.
/// * <https://material.io/design/components/navigation-drawer.html>
class NavigationDrawer extends StatelessWidget {
/// Creates a Material Design Navigation Drawer component.
const NavigationDrawer({
super.key,
required this.children,
this.backgroundColor,
this.shadowColor,
this.surfaceTintColor,
this.elevation,
this.indicatorColor,
this.indicatorShape,
this.onDestinationSelected,
this.selectedIndex = 0,
this.tilePadding = const EdgeInsets.symmetric(horizontal: 12.0),
});
/// The background color of the [Material] that holds the [NavigationDrawer]'s
/// contents.
///
/// If this is null, then [NavigationDrawerThemeData.backgroundColor] is used.
/// If that is also null, then it falls back to [ColorScheme.surface].
final Color? backgroundColor;
/// The color used for the drop shadow to indicate elevation.
///
/// If null, [NavigationDrawerThemeData.shadowColor] is used. If that
/// is also null, the default value is [Colors.transparent] which
/// indicates that no drop shadow will be displayed.
///
/// See [Material.shadowColor] for more details on drop shadows.
final Color? shadowColor;
/// The surface tint of the [Material] that holds the [NavigationDrawer]'s
/// contents.
///
/// If this is null, then [NavigationDrawerThemeData.surfaceTintColor] is used.
/// If that is also null, then it falls back to [Material.surfaceTintColor]'s default.
final Color? surfaceTintColor;
/// The elevation of the [NavigationDrawer] itself.
///
/// If null, [NavigationDrawerThemeData.elevation] is used. If that
/// is also null, it will be 1.0.
final double? elevation;
/// The color of the [indicatorShape] when this destination is selected.
///
/// If this is null, [NavigationDrawerThemeData.indicatorColor] is used.
/// If that is also null, defaults to [ColorScheme.secondaryContainer].
final Color? indicatorColor;
/// The shape of the selected indicator.
///
/// If this is null, [NavigationDrawerThemeData.indicatorShape] is used.
/// If that is also null, defaults to [StadiumBorder].
final ShapeBorder? indicatorShape;
/// Defines the appearance of the items within the navigation drawer.
///
/// The list contains [NavigationDrawerDestination] widgets and/or customized
/// widgets like headlines and dividers.
final List<Widget> children;
/// The index into destinations for the current selected
/// [NavigationDrawerDestination] or null if no destination is selected.
///
/// A valid [selectedIndex] satisfies 0 <= [selectedIndex] < number of [NavigationDrawerDestination].
/// For an invalid [selectedIndex] like `-1`, all destinations will appear unselected.
final int? selectedIndex;
/// Called when one of the [NavigationDrawerDestination] children is selected.
///
/// This callback usually updates the int passed to [selectedIndex].
///
/// Upon updating [selectedIndex], the [NavigationDrawer] will be rebuilt.
final ValueChanged<int>? onDestinationSelected;
/// Defines the padding for [NavigationDrawerDestination] widgets (Drawer items).
///
/// Defaults to `EdgeInsets.symmetric(horizontal: 12.0)`.
final EdgeInsetsGeometry tilePadding;
@override
Widget build(BuildContext context) {
final int totalNumberOfDestinations =
children.whereType<NavigationDrawerDestination>().toList().length;
int destinationIndex = 0;
final List<Widget> wrappedChildren = <Widget>[];
Widget wrapChild(Widget child, int index) => _SelectableAnimatedBuilder(
duration: const Duration(milliseconds: 500),
isSelected: index == selectedIndex,
builder: (BuildContext context, Animation<double> animation) {
return _NavigationDrawerDestinationInfo(
index: index,
totalNumberOfDestinations: totalNumberOfDestinations,
selectedAnimation: animation,
indicatorColor: indicatorColor,
indicatorShape: indicatorShape,
tilePadding: tilePadding,
onTap: () {
if (onDestinationSelected != null) {
onDestinationSelected!(index);
}
},
child: child,
);
});
for (int i = 0; i < children.length; i++) {
if (children[i] is! NavigationDrawerDestination) {
wrappedChildren.add(children[i]);
} else {
wrappedChildren.add(wrapChild(children[i], destinationIndex));
destinationIndex += 1;
}
}
final NavigationDrawerThemeData navigationDrawerTheme = NavigationDrawerTheme.of(context);
return Drawer(
backgroundColor: backgroundColor ?? navigationDrawerTheme.backgroundColor,
shadowColor: shadowColor ?? navigationDrawerTheme.shadowColor,
surfaceTintColor: surfaceTintColor ?? navigationDrawerTheme.surfaceTintColor,
elevation: elevation ?? navigationDrawerTheme.elevation,
child: SafeArea(
bottom: false,
child: ListView(
children: wrappedChildren,
),
),
);
}
}
/// A Material Design [NavigationDrawer] destination.
///
/// Displays an icon with a label, for use in [NavigationDrawer.children].
class NavigationDrawerDestination extends StatelessWidget {
/// Creates a navigation drawer destination.
const NavigationDrawerDestination({
super.key,
this.backgroundColor,
required this.icon,
this.selectedIcon,
required this.label,
});
/// Sets the color of the [Material] that holds all of the [Drawer]'s
/// contents.
///
/// If this is null, then [DrawerThemeData.backgroundColor] is used. If that
/// is also null, then it falls back to [Material]'s default.
final Color? backgroundColor;
/// The [Widget] (usually an [Icon]) that's displayed for this
/// [NavigationDestination].
///
/// The icon will use [NavigationDrawerThemeData.iconTheme]. If this is
/// null, the default [IconThemeData] would use a size of 24.0 and
/// [ColorScheme.onSurfaceVariant].
final Widget icon;
/// The optional [Widget] (usually an [Icon]) that's displayed when this
/// [NavigationDestination] is selected.
///
/// If [selectedIcon] is non-null, the destination will fade from
/// [icon] to [selectedIcon] when this destination goes from unselected to
/// selected.
///
/// The icon will use [NavigationDrawerThemeData.iconTheme] with
/// [MaterialState.selected]. If this is null, the default [IconThemeData]
/// would use a size of 24.0 and [ColorScheme.onSecondaryContainer].
final Widget? selectedIcon;
/// The text label that appears on the right of the icon
///
/// The accompanying [Text] widget will use
/// [NavigationDrawerThemeData.labelTextStyle]. If this are null, the default
/// text style would use [TextTheme.labelLarge] with [ColorScheme.onSurfaceVariant].
final Widget label;
@override
Widget build(BuildContext context) {
const Set<MaterialState> selectedState = <MaterialState>{
MaterialState.selected
};
const Set<MaterialState> unselectedState = <MaterialState>{};
final NavigationDrawerThemeData navigationDrawerTheme =
NavigationDrawerTheme.of(context);
final NavigationDrawerThemeData defaults =
_NavigationDrawerDefaultsM3(context);
final Animation<double> animation =
_NavigationDrawerDestinationInfo.of(context).selectedAnimation;
return _NavigationDestinationBuilder(
buildIcon: (BuildContext context) {
final Widget selectedIconWidget = IconTheme.merge(
data: navigationDrawerTheme.iconTheme?.resolve(selectedState) ??
defaults.iconTheme!.resolve(selectedState)!,
child: selectedIcon ?? icon,
);
final Widget unselectedIconWidget = IconTheme.merge(
data: navigationDrawerTheme.iconTheme?.resolve(unselectedState) ??
defaults.iconTheme!.resolve(unselectedState)!,
child: icon,
);
return _isForwardOrCompleted(animation)
? selectedIconWidget
: unselectedIconWidget;
},
buildLabel: (BuildContext context) {
final TextStyle? effectiveSelectedLabelTextStyle =
navigationDrawerTheme.labelTextStyle?.resolve(selectedState) ??
defaults.labelTextStyle!.resolve(selectedState);
final TextStyle? effectiveUnselectedLabelTextStyle =
navigationDrawerTheme.labelTextStyle?.resolve(unselectedState) ??
defaults.labelTextStyle!.resolve(unselectedState);
return DefaultTextStyle(
style: _isForwardOrCompleted(animation)
? effectiveSelectedLabelTextStyle!
: effectiveUnselectedLabelTextStyle!,
child: label,
);
},
);
}
}
/// Widget that handles the semantics and layout of a navigation drawer
/// destination.
///
/// Prefer [NavigationDestination] over this widget, as it is a simpler
/// (although less customizable) way to get navigation drawer destinations.
///
/// The icon and label of this destination are built with [buildIcon] and
/// [buildLabel]. They should build the unselected and selected icon and label
/// according to [_NavigationDrawerDestinationInfo.selectedAnimation], where an
/// animation value of 0 is unselected and 1 is selected.
///
/// See [NavigationDestination] for an example.
class _NavigationDestinationBuilder extends StatelessWidget {
/// Builds a destination (icon + label) to use in a Material 3 [NavigationDrawer].
const _NavigationDestinationBuilder({
required this.buildIcon,
required this.buildLabel,
});
/// Builds the icon for a destination in a [NavigationDrawer].
///
/// To animate between unselected and selected, build the icon based on
/// [_NavigationDrawerDestinationInfo.selectedAnimation]. When the animation is 0,
/// the destination is unselected, when the animation is 1, the destination is
/// selected.
///
/// The destination is considered selected as soon as the animation is
/// increasing or completed, and it is considered unselected as soon as the
/// animation is decreasing or dismissed.
final WidgetBuilder buildIcon;
/// Builds the label for a destination in a [NavigationDrawer].
///
/// To animate between unselected and selected, build the icon based on
/// [_NavigationDrawerDestinationInfo.selectedAnimation]. When the animation is
/// 0, the destination is unselected, when the animation is 1, the destination
/// is selected.
///
/// The destination is considered selected as soon as the animation is
/// increasing or completed, and it is considered unselected as soon as the
/// animation is decreasing or dismissed.
final WidgetBuilder buildLabel;
@override
Widget build(BuildContext context) {
final _NavigationDrawerDestinationInfo info = _NavigationDrawerDestinationInfo.of(context);
final NavigationDrawerThemeData navigationDrawerTheme = NavigationDrawerTheme.of(context);
final NavigationDrawerThemeData defaults = _NavigationDrawerDefaultsM3(context);
return Padding(
padding: info.tilePadding,
child: _NavigationDestinationSemantics(
child: SizedBox(
height: navigationDrawerTheme.tileHeight ?? defaults.tileHeight,
child: InkWell(
highlightColor: Colors.transparent,
onTap: info.onTap,
customBorder: info.indicatorShape ?? navigationDrawerTheme.indicatorShape ?? defaults.indicatorShape!,
child: Stack(
alignment: Alignment.center,
children: <Widget>[
NavigationIndicator(
animation: info.selectedAnimation,
color: info.indicatorColor ?? navigationDrawerTheme.indicatorColor ?? defaults.indicatorColor!,
shape: info.indicatorShape ?? navigationDrawerTheme.indicatorShape ?? defaults.indicatorShape!,
width: (navigationDrawerTheme.indicatorSize ?? defaults.indicatorSize!).width,
height: (navigationDrawerTheme.indicatorSize ?? defaults.indicatorSize!).height,
),
Row(
children: <Widget>[
const SizedBox(width: 16),
buildIcon(context),
const SizedBox(width: 12),
buildLabel(context),
],
),
],
),
),
),
),
);
}
}
/// Semantics widget for a navigation drawer destination.
///
/// Requires a [_NavigationDrawerDestinationInfo] parent (normally provided by the
/// [NavigationDrawer] by default).
///
/// Provides localized semantic labels to the destination, for example, it will
/// read "Home, Tab 1 of 3".
///
/// Used by [_NavigationDestinationBuilder].
class _NavigationDestinationSemantics extends StatelessWidget {
/// Adds the appropriate semantics for navigation drawer destinations to the
/// [child].
const _NavigationDestinationSemantics({
required this.child,
});
/// The widget that should receive the destination semantics.
final Widget child;
@override
Widget build(BuildContext context) {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final _NavigationDrawerDestinationInfo destinationInfo = _NavigationDrawerDestinationInfo.of(context);
// The AnimationStatusBuilder will make sure that the semantics update to
// "selected" when the animation status changes.
return _StatusTransitionWidgetBuilder(
animation: destinationInfo.selectedAnimation,
builder: (BuildContext context, Widget? child) {
return Semantics(
selected: _isForwardOrCompleted(destinationInfo.selectedAnimation),
container: true,
child: child,
);
},
child: Stack(
alignment: Alignment.center,
children: <Widget>[
child,
Semantics(
label: localizations.tabLabel(
tabIndex: destinationInfo.index + 1,
tabCount: destinationInfo.totalNumberOfDestinations,
),
),
],
),
);
}
}
/// Widget that listens to an animation, and rebuilds when the animation changes
/// [AnimationStatus].
///
/// This can be more efficient than just using an [AnimatedBuilder] when you
/// only need to rebuild when the [Animation.status] changes, since
/// [AnimatedBuilder] rebuilds every time the animation ticks.
class _StatusTransitionWidgetBuilder extends StatusTransitionWidget {
/// Creates a widget that rebuilds when the given animation changes status.
const _StatusTransitionWidgetBuilder({
required super.animation,
required this.builder,
this.child,
});
/// Called every time the [animation] changes [AnimationStatus].
final TransitionBuilder builder;
/// The child widget to pass to the [builder].
///
/// If a [builder] callback's return value contains a subtree that does not
/// depend on the animation, it's more efficient to build that subtree once
/// instead of rebuilding it on every animation status change.
///
/// Using this pre-built child is entirely optional, but can improve
/// performance in some cases and is therefore a good practice.
///
/// See: [AnimatedBuilder.child]
final Widget? child;
@override
Widget build(BuildContext context) => builder(context, child);
}
/// Inherited widget for passing data from the [NavigationDrawer] to the
/// [NavigationDrawer.destinations] children widgets.
///
/// Useful for building navigation destinations using:
/// `_NavigationDrawerDestinationInfo.of(context)`.
class _NavigationDrawerDestinationInfo extends InheritedWidget {
/// Adds the information needed to build a navigation destination to the
/// [child] and descendants.
const _NavigationDrawerDestinationInfo({
required this.index,
required this.totalNumberOfDestinations,
required this.selectedAnimation,
required this.indicatorColor,
required this.indicatorShape,
required this.onTap,
required super.child,
required this.tilePadding,
});
/// Which destination index is this in the navigation drawer.
///
/// For example:
///
/// ```dart
/// const NavigationDrawer(
/// children: <Widget>[
/// Text('Headline'), // This doesn't have index.
/// NavigationDrawerDestination(
/// // This is destination index 0.
/// icon: Icon(Icons.surfing),
/// label: Text('Surfing'),
/// ),
/// NavigationDrawerDestination(
/// // This is destination index 1.
/// icon: Icon(Icons.support),
/// label: Text('Support'),
/// ),
/// NavigationDrawerDestination(
/// // This is destination index 2.
/// icon: Icon(Icons.local_hospital),
/// label: Text('Hospital'),
/// ),
/// ]
/// )
/// ```
///
/// This is required for semantics, so that each destination can have a label
/// "Tab 1 of 3", for example.
final int index;
/// How many total destinations are in this navigation drawer.
///
/// This is required for semantics, so that each destination can have a label
/// "Tab 1 of 4", for example.
final int totalNumberOfDestinations;
/// Indicates whether or not this destination is selected, from 0 (unselected)
/// to 1 (selected).
final Animation<double> selectedAnimation;
/// The color of the indicator.
///
/// This is used by destinations to override the indicator color.
final Color? indicatorColor;
/// The shape of the indicator.
///
/// This is used by destinations to override the indicator shape.
final ShapeBorder? indicatorShape;
/// The callback that should be called when this destination is tapped.
///
/// This is computed by calling [NavigationDrawer.onDestinationSelected]
/// with [index] passed in.
final VoidCallback onTap;
/// Defines the padding for [NavigationDrawerDestination] widgets (Drawer items).
///
/// Defaults to `EdgeInsets.symmetric(horizontal: 12.0)`.
final EdgeInsetsGeometry tilePadding;
/// Returns a non null [_NavigationDrawerDestinationInfo].
///
/// This will return an error if called with no [_NavigationDrawerDestinationInfo]
/// ancestor.
///
/// Used by widgets that are implementing a navigation destination info to
/// get information like the selected animation and destination number.
static _NavigationDrawerDestinationInfo of(BuildContext context) {
final _NavigationDrawerDestinationInfo? result = context.dependOnInheritedWidgetOfExactType<_NavigationDrawerDestinationInfo>();
assert(
result != null,
'Navigation destinations need a _NavigationDrawerDestinationInfo parent, '
'which is usually provided by NavigationDrawer.',
);
return result!;
}
@override
bool updateShouldNotify(_NavigationDrawerDestinationInfo oldWidget) {
return index != oldWidget.index
|| totalNumberOfDestinations != oldWidget.totalNumberOfDestinations
|| selectedAnimation != oldWidget.selectedAnimation
|| onTap != oldWidget.onTap;
}
}
// Builder widget for widgets that need to be animated from 0 (unselected) to
// 1.0 (selected).
//
// This widget creates and manages an [AnimationController] that it passes down
// to the child through the [builder] function.
//
// When [isSelected] is `true`, the animation controller will animate from
// 0 to 1 (for [duration] time).
//
// When [isSelected] is `false`, the animation controller will animate from
// 1 to 0 (for [duration] time).
//
// If [isSelected] is updated while the widget is animating, the animation will
// be reversed until it is either 0 or 1 again.
//
// Usage:
// ```dart
// _SelectableAnimatedBuilder(
// isSelected: _isDrawerOpen,
// builder: (context, animation) {
// return AnimatedIcon(
// icon: AnimatedIcons.menu_arrow,
// progress: animation,
// semanticLabel: 'Show menu',
// );
// }
// )
// ```
class _SelectableAnimatedBuilder extends StatefulWidget {
/// Builds and maintains an [AnimationController] that will animate from 0 to
/// 1 and back depending on when [isSelected] is true.
const _SelectableAnimatedBuilder({
required this.isSelected,
this.duration = const Duration(milliseconds: 200),
required this.builder,
});
/// When true, the widget will animate an animation controller from 0 to 1.
///
/// The animation controller is passed to the child widget through [builder].
final bool isSelected;
/// How long the animation controller should animate for when [isSelected] is
/// updated.
///
/// If the animation is currently running and [isSelected] is updated, only
/// the [duration] left to finish the animation will be run.
final Duration duration;
/// Builds the child widget based on the current animation status.
///
/// When [isSelected] is updated to true, this builder will be called and the
/// animation will animate up to 1. When [isSelected] is updated to
/// `false`, this will be called and the animation will animate down to 0.
final Widget Function(BuildContext, Animation<double>) builder;
///
@override
_SelectableAnimatedBuilderState createState() => _SelectableAnimatedBuilderState();
}
/// State that manages the [AnimationController] that is passed to
/// [_SelectableAnimatedBuilder.builder].
class _SelectableAnimatedBuilderState extends State<_SelectableAnimatedBuilder>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
_controller.duration = widget.duration;
_controller.value = widget.isSelected ? 1.0 : 0.0;
}
@override
void didUpdateWidget(_SelectableAnimatedBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.duration != widget.duration) {
_controller.duration = widget.duration;
}
if (oldWidget.isSelected != widget.isSelected) {
if (widget.isSelected) {
_controller.forward();
} else {
_controller.reverse();
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.builder(
context,
_controller,
);
}
}
/// Returns `true` if this animation is ticking forward, or has completed,
/// based on [status].
bool _isForwardOrCompleted(Animation<double> animation) {
return animation.status == AnimationStatus.forward || animation.status == AnimationStatus.completed;
}
// BEGIN GENERATED TOKEN PROPERTIES - NavigationDrawer
// Do not edit by hand. The code between the "BEGIN GENERATED" and
// "END GENERATED" comments are generated from data in the Material
// Design token database by the script:
// dev/tools/gen_defaults/bin/gen_defaults.dart.
class _NavigationDrawerDefaultsM3 extends NavigationDrawerThemeData {
_NavigationDrawerDefaultsM3(this.context)
: super(
elevation: 1.0,
tileHeight: 56.0,
indicatorShape: const StadiumBorder(),
indicatorSize: const Size(336.0, 56.0),
);
final BuildContext context;
late final ColorScheme _colors = Theme.of(context).colorScheme;
late final TextTheme _textTheme = Theme.of(context).textTheme;
@override
Color? get backgroundColor => _colors.surface;
@override
Color? get surfaceTintColor => _colors.surfaceTint;
@override
Color? get shadowColor => Colors.transparent;
@override
Color? get indicatorColor => _colors.secondaryContainer;
@override
MaterialStateProperty<IconThemeData?>? get iconTheme {
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
return IconThemeData(
size: 24.0,
color: states.contains(MaterialState.selected)
? _colors.onSecondaryContainer
: _colors.onSurfaceVariant,
);
});
}
@override
MaterialStateProperty<TextStyle?>? get labelTextStyle {
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
final TextStyle style = _textTheme.labelLarge!;
return style.apply(
color: states.contains(MaterialState.selected)
? _colors.onSecondaryContainer
: _colors.onSurfaceVariant,
);
});
}
}
// END GENERATED TOKEN PROPERTIES - NavigationDrawer