| // 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 'dart:ui'; |
| |
| import 'package:flutter/widgets.dart'; |
| |
| import 'color_scheme.dart'; |
| import 'ink_well.dart'; |
| import 'material.dart'; |
| import 'material_localizations.dart'; |
| import 'navigation_bar.dart'; |
| import 'navigation_rail_theme.dart'; |
| import 'text_theme.dart'; |
| import 'theme.dart'; |
| |
| const double _kCircularIndicatorDiameter = 56; |
| const double _kIndicatorHeight = 32; |
| |
| /// A Material Design widget that is meant to be displayed at the left or right of an |
| /// app to navigate between a small number of views, typically between three and |
| /// five. |
| /// |
| /// {@youtube 560 315 https://www.youtube.com/watch?v=y9xchtVTtqQ} |
| /// |
| /// The navigation rail is meant for layouts with wide viewports, such as a |
| /// desktop web or tablet landscape layout. For smaller layouts, like mobile |
| /// portrait, a [BottomNavigationBar] should be used instead. |
| /// |
| /// A navigation rail is usually used as the first or last element of a [Row] |
| /// which defines the app's [Scaffold] body. |
| /// |
| /// The appearance of all of the [NavigationRail]s within an app can be |
| /// specified with [NavigationRailTheme]. The default values for null theme |
| /// properties are based on the [Theme]'s [ThemeData.textTheme], |
| /// [ThemeData.iconTheme], and [ThemeData.colorScheme]. |
| /// |
| /// Adaptive layouts can build different instances of the [Scaffold] in order to |
| /// have a navigation rail for more horizontal layouts and a bottom navigation |
| /// bar for more vertical layouts. See |
| /// [the adaptive_scaffold.dart sample](https://github.com/flutter/samples/blob/master/experimental/web_dashboard/lib/src/widgets/third_party/adaptive_scaffold.dart) |
| /// for an example. |
| /// |
| /// {@tool dartpad} |
| /// This example shows a [NavigationRail] used within a Scaffold with 3 |
| /// [NavigationRailDestination]s. The main content is separated by a divider |
| /// (although elevation on the navigation rail can be used instead). The |
| /// `_selectedIndex` is updated by the `onDestinationSelected` callback. |
| /// |
| /// ** See code in examples/api/lib/material/navigation_rail/navigation_rail.0.dart ** |
| /// {@end-tool} |
| /// |
| /// {@tool dartpad} |
| /// This sample shows the creation of [NavigationRail] widget used within a Scaffold with 3 |
| /// [NavigationRailDestination]s, as described in: https://m3.material.io/components/navigation-rail/overview |
| /// |
| /// ** See code in examples/api/lib/material/navigation_rail/navigation_rail.1.dart ** |
| /// {@end-tool} |
| /// |
| /// See also: |
| /// |
| /// * [Scaffold], which can display the navigation rail within a [Row] of the |
| /// [Scaffold.body] slot. |
| /// * [NavigationRailDestination], which is used as a model to create tappable |
| /// destinations in the navigation rail. |
| /// * [BottomNavigationBar], which is a similar navigation widget that's laid |
| /// out horizontally. |
| /// * <https://material.io/components/navigation-rail/> |
| /// * <https://m3.material.io/components/navigation-rail> |
| class NavigationRail extends StatefulWidget { |
| /// Creates a Material Design navigation rail. |
| /// |
| /// The value of [destinations] must be a list of two or more |
| /// [NavigationRailDestination] values. |
| /// |
| /// If [elevation] is specified, it must be non-negative. |
| /// |
| /// If [minWidth] is specified, it must be non-negative, and if |
| /// [minExtendedWidth] is specified, it must be non-negative and greater than |
| /// [minWidth]. |
| /// |
| /// The argument [extended] must not be null. [extended] can only be set to |
| /// true when the [labelType] is null or [NavigationRailLabelType.none]. |
| /// |
| /// If [backgroundColor], [elevation], [groupAlignment], [labelType], |
| /// [unselectedLabelTextStyle], [selectedLabelTextStyle], |
| /// [unselectedIconTheme], or [selectedIconTheme] are null, then their |
| /// [NavigationRailThemeData] values will be used. If the corresponding |
| /// [NavigationRailThemeData] property is null, then the navigation rail |
| /// defaults are used. See the individual properties for more information. |
| /// |
| /// Typically used within a [Row] that defines the [Scaffold.body] property. |
| const NavigationRail({ |
| super.key, |
| this.backgroundColor, |
| this.extended = false, |
| this.leading, |
| this.trailing, |
| required this.destinations, |
| required this.selectedIndex, |
| this.onDestinationSelected, |
| this.elevation, |
| this.groupAlignment, |
| this.labelType, |
| this.unselectedLabelTextStyle, |
| this.selectedLabelTextStyle, |
| this.unselectedIconTheme, |
| this.selectedIconTheme, |
| this.minWidth, |
| this.minExtendedWidth, |
| this.useIndicator, |
| this.indicatorColor, |
| this.indicatorShape, |
| }) : assert(destinations.length >= 2), |
| assert(selectedIndex == null || (0 <= selectedIndex && selectedIndex < destinations.length)), |
| assert(elevation == null || elevation > 0), |
| assert(minWidth == null || minWidth > 0), |
| assert(minExtendedWidth == null || minExtendedWidth > 0), |
| assert((minWidth == null || minExtendedWidth == null) || minExtendedWidth >= minWidth), |
| assert(!extended || (labelType == null || labelType == NavigationRailLabelType.none)); |
| |
| /// Sets the color of the Container that holds all of the [NavigationRail]'s |
| /// contents. |
| /// |
| /// The default value is [NavigationRailThemeData.backgroundColor]. If |
| /// [NavigationRailThemeData.backgroundColor] is null, then the default value |
| /// is based on [ColorScheme.surface] of [ThemeData.colorScheme]. |
| final Color? backgroundColor; |
| |
| /// Indicates that the [NavigationRail] should be in the extended state. |
| /// |
| /// The extended state has a wider rail container, and the labels are |
| /// positioned next to the icons. [minExtendedWidth] can be used to set the |
| /// minimum width of the rail when it is in this state. |
| /// |
| /// The rail will implicitly animate between the extended and normal state. |
| /// |
| /// If the rail is going to be in the extended state, then the [labelType] |
| /// must be set to [NavigationRailLabelType.none]. |
| /// |
| /// The default value is false. |
| final bool extended; |
| |
| /// The leading widget in the rail that is placed above the destinations. |
| /// |
| /// It is placed at the top of the rail, above the [destinations]. Its |
| /// location is not affected by [groupAlignment]. |
| /// |
| /// This is commonly a [FloatingActionButton], but may also be a non-button, |
| /// such as a logo. |
| /// |
| /// The default value is null. |
| final Widget? leading; |
| |
| /// The trailing widget in the rail that is placed below the destinations. |
| /// |
| /// The trailing widget is placed below the last [NavigationRailDestination]. |
| /// It's location is affected by [groupAlignment]. |
| /// |
| /// This is commonly a list of additional options or destinations that is |
| /// usually only rendered when [extended] is true. |
| /// |
| /// The default value is null. |
| final Widget? trailing; |
| |
| /// Defines the appearance of the button items that are arrayed within the |
| /// navigation rail. |
| /// |
| /// The value must be a list of two or more [NavigationRailDestination] |
| /// values. |
| final List<NavigationRailDestination> destinations; |
| |
| /// The index into [destinations] for the current selected |
| /// [NavigationRailDestination] or null if no destination is selected. |
| final int? selectedIndex; |
| |
| /// Called when one of the [destinations] is selected. |
| /// |
| /// The stateful widget that creates the navigation rail needs to keep |
| /// track of the index of the selected [NavigationRailDestination] and call |
| /// `setState` to rebuild the navigation rail with the new [selectedIndex]. |
| final ValueChanged<int>? onDestinationSelected; |
| |
| /// The rail's elevation or z-coordinate. |
| /// |
| /// If [Directionality] is [intl.TextDirection.LTR], the inner side is the |
| /// right side, and if [Directionality] is [intl.TextDirection.RTL], it is |
| /// the left side. |
| /// |
| /// The default value is 0. |
| final double? elevation; |
| |
| /// The vertical alignment for the group of [destinations] within the rail. |
| /// |
| /// The [NavigationRailDestination]s are grouped together with the [trailing] |
| /// widget, between the [leading] widget and the bottom of the rail. |
| /// |
| /// The value must be between -1.0 and 1.0. |
| /// |
| /// If [groupAlignment] is -1.0, then the items are aligned to the top. If |
| /// [groupAlignment] is 0.0, then the items are aligned to the center. If |
| /// [groupAlignment] is 1.0, then the items are aligned to the bottom. |
| /// |
| /// The default is -1.0. |
| /// |
| /// See also: |
| /// * [Alignment.y] |
| /// |
| final double? groupAlignment; |
| |
| /// Defines the layout and behavior of the labels for the default, unextended |
| /// [NavigationRail]. |
| /// |
| /// When a navigation rail is [extended], the labels are always shown. |
| /// |
| /// The default value is [NavigationRailThemeData.labelType]. If |
| /// [NavigationRailThemeData.labelType] is null, then the default value is |
| /// [NavigationRailLabelType.none]. |
| /// |
| /// See also: |
| /// |
| /// * [NavigationRailLabelType] for information on the meaning of different |
| /// types. |
| final NavigationRailLabelType? labelType; |
| |
| /// The [TextStyle] of a destination's label when it is unselected. |
| /// |
| /// When one of the [destinations] is selected the [selectedLabelTextStyle] |
| /// will be used instead. |
| /// |
| /// The default value is based on the [Theme]'s [TextTheme.bodyLarge]. The |
| /// default color is based on the [Theme]'s [ColorScheme.onSurface]. |
| /// |
| /// Properties from this text style, or |
| /// [NavigationRailThemeData.unselectedLabelTextStyle] if this is null, are |
| /// merged into the defaults. |
| final TextStyle? unselectedLabelTextStyle; |
| |
| /// The [TextStyle] of a destination's label when it is selected. |
| /// |
| /// When a [NavigationRailDestination] is not selected, |
| /// [unselectedLabelTextStyle] will be used. |
| /// |
| /// The default value is based on the [TextTheme.bodyLarge] of |
| /// [ThemeData.textTheme]. The default color is based on the [Theme]'s |
| /// [ColorScheme.primary]. |
| /// |
| /// Properties from this text style, |
| /// or [NavigationRailThemeData.selectedLabelTextStyle] if this is null, are |
| /// merged into the defaults. |
| final TextStyle? selectedLabelTextStyle; |
| |
| /// The visual properties of the icon in the unselected destination. |
| /// |
| /// If this field is not provided, or provided with any null properties, then |
| /// a copy of the [IconThemeData.fallback] with a custom [NavigationRail] |
| /// specific color will be used. |
| /// |
| /// The default value is the [Theme]'s [ThemeData.iconTheme] with a color |
| /// of the [Theme]'s [ColorScheme.onSurface] with an opacity of 0.64. |
| /// Properties from this icon theme, or |
| /// [NavigationRailThemeData.unselectedIconTheme] if this is null, are |
| /// merged into the defaults. |
| final IconThemeData? unselectedIconTheme; |
| |
| /// The visual properties of the icon in the selected destination. |
| /// |
| /// When a [NavigationRailDestination] is not selected, |
| /// [unselectedIconTheme] will be used. |
| /// |
| /// The default value is the [Theme]'s [ThemeData.iconTheme] with a color |
| /// of the [Theme]'s [ColorScheme.primary]. Properties from this icon theme, |
| /// or [NavigationRailThemeData.selectedIconTheme] if this is null, are |
| /// merged into the defaults. |
| final IconThemeData? selectedIconTheme; |
| |
| /// The smallest possible width for the rail regardless of the destination's |
| /// icon or label size. |
| /// |
| /// The default is 72. |
| /// |
| /// This value also defines the min width and min height of the destinations. |
| /// |
| /// To make a compact rail, set this to 56 and use |
| /// [NavigationRailLabelType.none]. |
| final double? minWidth; |
| |
| /// The final width when the animation is complete for setting [extended] to |
| /// true. |
| /// |
| /// This is only used when [extended] is set to true. |
| /// |
| /// The default value is 256. |
| final double? minExtendedWidth; |
| |
| /// If `true`, adds a rounded [NavigationIndicator] behind the selected |
| /// destination's icon. |
| /// |
| /// The indicator's shape will be circular if [labelType] is |
| /// [NavigationRailLabelType.none], or a [StadiumBorder] if [labelType] is |
| /// [NavigationRailLabelType.all] or [NavigationRailLabelType.selected]. |
| /// |
| /// If `null`, defaults to [NavigationRailThemeData.useIndicator]. If that is |
| /// `null`, defaults to [ThemeData.useMaterial3]. |
| final bool? useIndicator; |
| |
| /// Overrides the default value of [NavigationRail]'s selection indicator color, |
| /// when [useIndicator] is true. |
| /// |
| /// If this is null, [NavigationRailThemeData.indicatorColor] is used. If |
| /// that is null, defaults to [ColorScheme.secondaryContainer]. |
| final Color? indicatorColor; |
| |
| /// Overrides the default value of [NavigationRail]'s selection indicator shape, |
| /// when [useIndicator] is true. |
| /// |
| /// If this is null, [NavigationRailThemeData.indicatorShape] is used. If |
| /// that is null, defaults to [StadiumBorder]. |
| final ShapeBorder? indicatorShape; |
| |
| /// Returns the animation that controls the [NavigationRail.extended] state. |
| /// |
| /// This can be used to synchronize animations in the [leading] or [trailing] |
| /// widget, such as an animated menu or a [FloatingActionButton] animation. |
| /// |
| /// {@tool dartpad} |
| /// This example shows how to use this animation to create a [FloatingActionButton] |
| /// that animates itself between the normal and extended states of the |
| /// [NavigationRail]. |
| /// |
| /// An instance of `MyNavigationRailFab` is created for [NavigationRail.leading]. |
| /// Pressing the FAB button toggles the "extended" state of the [NavigationRail]. |
| /// |
| /// ** See code in examples/api/lib/material/navigation_rail/navigation_rail.extended_animation.0.dart ** |
| /// {@end-tool} |
| static Animation<double> extendedAnimation(BuildContext context) { |
| return context.dependOnInheritedWidgetOfExactType<_ExtendedNavigationRailAnimation>()!.animation; |
| } |
| |
| @override |
| State<NavigationRail> createState() => _NavigationRailState(); |
| } |
| |
| class _NavigationRailState extends State<NavigationRail> with TickerProviderStateMixin { |
| late List<AnimationController> _destinationControllers; |
| late List<Animation<double>> _destinationAnimations; |
| late AnimationController _extendedController; |
| late Animation<double> _extendedAnimation; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _initControllers(); |
| } |
| |
| @override |
| void dispose() { |
| _disposeControllers(); |
| super.dispose(); |
| } |
| |
| @override |
| void didUpdateWidget(NavigationRail oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| |
| if (widget.extended != oldWidget.extended) { |
| if (widget.extended) { |
| _extendedController.forward(); |
| } else { |
| _extendedController.reverse(); |
| } |
| } |
| |
| // No animated segue if the length of the items list changes. |
| if (widget.destinations.length != oldWidget.destinations.length) { |
| _resetState(); |
| return; |
| } |
| |
| if (widget.selectedIndex != oldWidget.selectedIndex) { |
| if (oldWidget.selectedIndex != null) { |
| _destinationControllers[oldWidget.selectedIndex!].reverse(); |
| } |
| if (widget.selectedIndex != null) { |
| _destinationControllers[widget.selectedIndex!].forward(); |
| } |
| return; |
| } |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final NavigationRailThemeData navigationRailTheme = NavigationRailTheme.of(context); |
| final NavigationRailThemeData defaults = Theme.of(context).useMaterial3 ? _NavigationRailDefaultsM3(context) : _NavigationRailDefaultsM2(context); |
| final MaterialLocalizations localizations = MaterialLocalizations.of(context); |
| |
| final Color backgroundColor = widget.backgroundColor ?? navigationRailTheme.backgroundColor ?? defaults.backgroundColor!; |
| final double elevation = widget.elevation ?? navigationRailTheme.elevation ?? defaults.elevation!; |
| final double minWidth = widget.minWidth ?? navigationRailTheme.minWidth ?? defaults.minWidth!; |
| final double minExtendedWidth = widget.minExtendedWidth ?? navigationRailTheme.minExtendedWidth ?? defaults.minExtendedWidth!; |
| final TextStyle unselectedLabelTextStyle = widget.unselectedLabelTextStyle ?? navigationRailTheme.unselectedLabelTextStyle ?? defaults.unselectedLabelTextStyle!; |
| final TextStyle selectedLabelTextStyle = widget.selectedLabelTextStyle ?? navigationRailTheme.selectedLabelTextStyle ?? defaults.selectedLabelTextStyle!; |
| final IconThemeData unselectedIconTheme = widget.unselectedIconTheme ?? navigationRailTheme.unselectedIconTheme ?? defaults.unselectedIconTheme!; |
| final IconThemeData selectedIconTheme = widget.selectedIconTheme ?? navigationRailTheme.selectedIconTheme ?? defaults.selectedIconTheme!; |
| final double groupAlignment = widget.groupAlignment ?? navigationRailTheme.groupAlignment ?? defaults.groupAlignment!; |
| final NavigationRailLabelType labelType = widget.labelType ?? navigationRailTheme.labelType ?? defaults.labelType!; |
| final bool useIndicator = widget.useIndicator ?? navigationRailTheme.useIndicator ?? defaults.useIndicator!; |
| final Color? indicatorColor = widget.indicatorColor ?? navigationRailTheme.indicatorColor ?? defaults.indicatorColor; |
| final ShapeBorder? indicatorShape = widget.indicatorShape ?? navigationRailTheme.indicatorShape ?? defaults.indicatorShape; |
| |
| // For backwards compatibility, in M2 the opacity of the unselected icons needs |
| // to be set to the default if it isn't in the given theme. This can be removed |
| // when Material 3 is the default. |
| final IconThemeData effectiveUnselectedIconTheme = Theme.of(context).useMaterial3 |
| ? unselectedIconTheme |
| : unselectedIconTheme.copyWith(opacity: unselectedIconTheme.opacity ?? defaults.unselectedIconTheme!.opacity); |
| |
| final bool isRTLDirection = Directionality.of(context) == TextDirection.rtl; |
| |
| return _ExtendedNavigationRailAnimation( |
| animation: _extendedAnimation, |
| child: Semantics( |
| explicitChildNodes: true, |
| child: Material( |
| elevation: elevation, |
| color: backgroundColor, |
| child: SafeArea( |
| right: isRTLDirection, |
| left: !isRTLDirection, |
| child: Column( |
| children: <Widget>[ |
| _verticalSpacer, |
| if (widget.leading != null) |
| ...<Widget>[ |
| widget.leading!, |
| _verticalSpacer, |
| ], |
| Expanded( |
| child: Align( |
| alignment: Alignment(0, groupAlignment), |
| child: Column( |
| mainAxisSize: MainAxisSize.min, |
| children: <Widget>[ |
| for (int i = 0; i < widget.destinations.length; i += 1) |
| _RailDestination( |
| minWidth: minWidth, |
| minExtendedWidth: minExtendedWidth, |
| extendedTransitionAnimation: _extendedAnimation, |
| selected: widget.selectedIndex == i, |
| icon: widget.selectedIndex == i ? widget.destinations[i].selectedIcon : widget.destinations[i].icon, |
| label: widget.destinations[i].label, |
| destinationAnimation: _destinationAnimations[i], |
| labelType: labelType, |
| iconTheme: widget.selectedIndex == i ? selectedIconTheme : effectiveUnselectedIconTheme, |
| labelTextStyle: widget.selectedIndex == i ? selectedLabelTextStyle : unselectedLabelTextStyle, |
| padding: widget.destinations[i].padding, |
| useIndicator: useIndicator, |
| indicatorColor: useIndicator ? indicatorColor : null, |
| indicatorShape: useIndicator ? indicatorShape : null, |
| onTap: () { |
| if (widget.onDestinationSelected != null) { |
| widget.onDestinationSelected!(i); |
| } |
| }, |
| indexLabel: localizations.tabLabel( |
| tabIndex: i + 1, |
| tabCount: widget.destinations.length, |
| ), |
| disabled: widget.destinations[i].disabled, |
| ), |
| if (widget.trailing != null) |
| widget.trailing!, |
| ], |
| ), |
| ), |
| ), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| void _disposeControllers() { |
| for (final AnimationController controller in _destinationControllers) { |
| controller.dispose(); |
| } |
| _extendedController.dispose(); |
| } |
| |
| void _initControllers() { |
| _destinationControllers = List<AnimationController>.generate(widget.destinations.length, (int index) { |
| return AnimationController( |
| duration: kThemeAnimationDuration, |
| vsync: this, |
| )..addListener(_rebuild); |
| }); |
| _destinationAnimations = _destinationControllers.map((AnimationController controller) => controller.view).toList(); |
| if (widget.selectedIndex != null) { |
| _destinationControllers[widget.selectedIndex!].value = 1.0; |
| } |
| _extendedController = AnimationController( |
| duration: kThemeAnimationDuration, |
| vsync: this, |
| value: widget.extended ? 1.0 : 0.0, |
| ); |
| _extendedAnimation = CurvedAnimation( |
| parent: _extendedController, |
| curve: Curves.easeInOut, |
| ); |
| _extendedController.addListener(() { |
| _rebuild(); |
| }); |
| } |
| |
| void _resetState() { |
| _disposeControllers(); |
| _initControllers(); |
| } |
| |
| void _rebuild() { |
| setState(() { |
| // Rebuilding when any of the controllers tick, i.e. when the items are |
| // animating. |
| }); |
| } |
| } |
| |
| class _RailDestination extends StatelessWidget { |
| _RailDestination({ |
| required this.minWidth, |
| required this.minExtendedWidth, |
| required this.icon, |
| required this.label, |
| required this.destinationAnimation, |
| required this.extendedTransitionAnimation, |
| required this.labelType, |
| required this.selected, |
| required this.iconTheme, |
| required this.labelTextStyle, |
| required this.onTap, |
| required this.indexLabel, |
| this.padding, |
| required this.useIndicator, |
| this.indicatorColor, |
| this.indicatorShape, |
| this.disabled = false, |
| }) : _positionAnimation = CurvedAnimation( |
| parent: ReverseAnimation(destinationAnimation), |
| curve: Curves.easeInOut, |
| reverseCurve: Curves.easeInOut.flipped, |
| ); |
| |
| final double minWidth; |
| final double minExtendedWidth; |
| final Widget icon; |
| final Widget label; |
| final Animation<double> destinationAnimation; |
| final NavigationRailLabelType labelType; |
| final bool selected; |
| final Animation<double> extendedTransitionAnimation; |
| final IconThemeData iconTheme; |
| final TextStyle labelTextStyle; |
| final VoidCallback onTap; |
| final String indexLabel; |
| final EdgeInsetsGeometry? padding; |
| final bool useIndicator; |
| final Color? indicatorColor; |
| final ShapeBorder? indicatorShape; |
| final bool disabled; |
| |
| final Animation<double> _positionAnimation; |
| |
| @override |
| Widget build(BuildContext context) { |
| assert( |
| useIndicator || indicatorColor == null, |
| '[NavigationRail.indicatorColor] does not have an effect when [NavigationRail.useIndicator] is false', |
| ); |
| |
| final ThemeData theme = Theme.of(context); |
| |
| final bool material3 = theme.useMaterial3; |
| final EdgeInsets destinationPadding = (padding ?? EdgeInsets.zero).resolve(Directionality.of(context)); |
| Offset indicatorOffset; |
| bool applyXOffset = false; |
| |
| final Widget themedIcon = IconTheme( |
| data: disabled |
| ? iconTheme.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.38)) |
| : iconTheme, |
| child: icon, |
| ); |
| final Widget styledLabel = DefaultTextStyle( |
| style: labelTextStyle, |
| child: label, |
| ); |
| |
| Widget content; |
| |
| switch (labelType) { |
| case NavigationRailLabelType.none: |
| // Split the destination spacing across the top and bottom to keep the icon centered. |
| final Widget? spacing = material3 ? const SizedBox(height: _verticalDestinationSpacingM3 / 2) : null; |
| indicatorOffset = Offset( |
| minWidth / 2 + destinationPadding.left, |
| _verticalDestinationSpacingM3 / 2 + destinationPadding.top, |
| ); |
| final Widget iconPart = Column( |
| children: <Widget>[ |
| if (spacing != null) spacing, |
| SizedBox( |
| width: minWidth, |
| height: material3 ? null : minWidth, |
| child: Center( |
| child: _AddIndicator( |
| addIndicator: useIndicator, |
| indicatorColor: indicatorColor, |
| indicatorShape: indicatorShape, |
| isCircular: !material3, |
| indicatorAnimation: destinationAnimation, |
| child: themedIcon, |
| ), |
| ), |
| ), |
| if (spacing != null) spacing, |
| ], |
| ); |
| if (extendedTransitionAnimation.value == 0) { |
| content = Padding( |
| padding: padding ?? EdgeInsets.zero, |
| child: Stack( |
| children: <Widget>[ |
| iconPart, |
| // For semantics when label is not showing, |
| SizedBox.shrink( |
| child: Visibility.maintain( |
| visible: false, |
| child: label, |
| ), |
| ), |
| ], |
| ), |
| ); |
| } else { |
| final Animation<double> labelFadeAnimation = extendedTransitionAnimation.drive(CurveTween(curve: const Interval(0.0, 0.25))); |
| applyXOffset = true; |
| content = Padding( |
| padding: padding ?? EdgeInsets.zero, |
| child: ConstrainedBox( |
| constraints: BoxConstraints( |
| minWidth: lerpDouble(minWidth, minExtendedWidth, extendedTransitionAnimation.value)!, |
| ), |
| child: ClipRect( |
| child: Row( |
| children: <Widget>[ |
| iconPart, |
| Align( |
| heightFactor: 1.0, |
| widthFactor: extendedTransitionAnimation.value, |
| alignment: AlignmentDirectional.centerStart, |
| child: FadeTransition( |
| alwaysIncludeSemantics: true, |
| opacity: labelFadeAnimation, |
| child: styledLabel, |
| ), |
| ), |
| SizedBox(width: _horizontalDestinationPadding * extendedTransitionAnimation.value), |
| ], |
| ), |
| ), |
| ), |
| ); |
| } |
| case NavigationRailLabelType.selected: |
| final double appearingAnimationValue = 1 - _positionAnimation.value; |
| final double verticalPadding = lerpDouble(_verticalDestinationPaddingNoLabel, _verticalDestinationPaddingWithLabel, appearingAnimationValue)!; |
| final Interval interval = selected ? const Interval(0.25, 0.75) : const Interval(0.75, 1.0); |
| final Animation<double> labelFadeAnimation = destinationAnimation.drive(CurveTween(curve: interval)); |
| final double minHeight = material3 ? 0 : minWidth; |
| final Widget topSpacing = SizedBox(height: material3 ? 0 : verticalPadding); |
| final Widget labelSpacing = SizedBox(height: material3 ? lerpDouble(0, _verticalIconLabelSpacingM3, appearingAnimationValue)! : 0); |
| final Widget bottomSpacing = SizedBox(height: material3 ? _verticalDestinationSpacingM3 : verticalPadding); |
| final double indicatorHorizontalPadding = (destinationPadding.left / 2) - (destinationPadding.right / 2); |
| final double indicatorVerticalPadding = destinationPadding.top; |
| indicatorOffset = Offset(minWidth / 2 + indicatorHorizontalPadding, indicatorVerticalPadding); |
| if (minWidth < _NavigationRailDefaultsM2(context).minWidth!) { |
| indicatorOffset = Offset(minWidth / 2 + _horizontalDestinationSpacingM3, indicatorVerticalPadding); |
| } |
| content = Container( |
| constraints: BoxConstraints( |
| minWidth: minWidth, |
| minHeight: minHeight, |
| ), |
| padding: padding ?? const EdgeInsets.symmetric(horizontal: _horizontalDestinationPadding), |
| child: ClipRect( |
| child: Column( |
| mainAxisSize: MainAxisSize.min, |
| mainAxisAlignment: MainAxisAlignment.center, |
| children: <Widget>[ |
| topSpacing, |
| _AddIndicator( |
| addIndicator: useIndicator, |
| indicatorColor: indicatorColor, |
| indicatorShape: indicatorShape, |
| isCircular: false, |
| indicatorAnimation: destinationAnimation, |
| child: themedIcon, |
| ), |
| labelSpacing, |
| Align( |
| alignment: Alignment.topCenter, |
| heightFactor: appearingAnimationValue, |
| widthFactor: 1.0, |
| child: FadeTransition( |
| alwaysIncludeSemantics: true, |
| opacity: labelFadeAnimation, |
| child: styledLabel, |
| ), |
| ), |
| bottomSpacing, |
| ], |
| ), |
| ), |
| ); |
| case NavigationRailLabelType.all: |
| final double minHeight = material3 ? 0 : minWidth; |
| final Widget topSpacing = SizedBox(height: material3 ? 0 : _verticalDestinationPaddingWithLabel); |
| final Widget labelSpacing = SizedBox(height: material3 ? _verticalIconLabelSpacingM3 : 0); |
| final Widget bottomSpacing = SizedBox(height: material3 ? _verticalDestinationSpacingM3 : _verticalDestinationPaddingWithLabel); |
| final double indicatorHorizontalPadding = (destinationPadding.left / 2) - (destinationPadding.right / 2); |
| final double indicatorVerticalPadding = destinationPadding.top; |
| indicatorOffset = Offset(minWidth / 2 + indicatorHorizontalPadding, indicatorVerticalPadding); |
| if (minWidth < _NavigationRailDefaultsM2(context).minWidth!) { |
| indicatorOffset = Offset(minWidth / 2 + _horizontalDestinationSpacingM3, indicatorVerticalPadding); |
| } |
| content = Container( |
| constraints: BoxConstraints( |
| minWidth: minWidth, |
| minHeight: minHeight, |
| ), |
| padding: padding ?? const EdgeInsets.symmetric(horizontal: _horizontalDestinationPadding), |
| child: Column( |
| children: <Widget>[ |
| topSpacing, |
| _AddIndicator( |
| addIndicator: useIndicator, |
| indicatorColor: indicatorColor, |
| indicatorShape: indicatorShape, |
| isCircular: false, |
| indicatorAnimation: destinationAnimation, |
| child: themedIcon, |
| ), |
| labelSpacing, |
| styledLabel, |
| bottomSpacing, |
| ], |
| ), |
| ); |
| } |
| |
| final ColorScheme colors = Theme.of(context).colorScheme; |
| return Semantics( |
| container: true, |
| selected: selected, |
| child: Stack( |
| children: <Widget>[ |
| Material( |
| type: MaterialType.transparency, |
| child: _IndicatorInkWell( |
| onTap: disabled ? null : onTap, |
| borderRadius: BorderRadius.all(Radius.circular(minWidth / 2.0)), |
| customBorder: indicatorShape, |
| splashColor: colors.primary.withOpacity(0.12), |
| hoverColor: colors.primary.withOpacity(0.04), |
| useMaterial3: material3, |
| indicatorOffset: indicatorOffset, |
| applyXOffset: applyXOffset, |
| child: content, |
| ), |
| ), |
| Semantics( |
| label: indexLabel, |
| ), |
| ], |
| ), |
| ); |
| } |
| } |
| |
| class _IndicatorInkWell extends InkResponse { |
| const _IndicatorInkWell({ |
| super.child, |
| super.onTap, |
| ShapeBorder? customBorder, |
| BorderRadius? borderRadius, |
| super.splashColor, |
| super.hoverColor, |
| required this.useMaterial3, |
| required this.indicatorOffset, |
| required this.applyXOffset, |
| }) : super( |
| containedInkWell: true, |
| highlightShape: BoxShape.rectangle, |
| borderRadius: useMaterial3 ? null : borderRadius, |
| customBorder: useMaterial3 ? customBorder : null, |
| ); |
| |
| final bool useMaterial3; |
| // The offset used to position Ink highlight. |
| final Offset indicatorOffset; |
| // Whether the horizontal offset from indicatorOffset should be used to position Ink highlight. |
| // If true, Ink highlight uses the indicator horizontal offset. If false, Ink highlight is centered horizontally. |
| final bool applyXOffset; |
| |
| @override |
| RectCallback? getRectCallback(RenderBox referenceBox) { |
| if (useMaterial3) { |
| final double indicatorHorizontalCenter = applyXOffset ? indicatorOffset.dx : referenceBox.size.width / 2; |
| return () { |
| return Rect.fromLTWH( |
| indicatorHorizontalCenter - (_kCircularIndicatorDiameter / 2), |
| indicatorOffset.dy, |
| _kCircularIndicatorDiameter, |
| _kIndicatorHeight, |
| ); |
| }; |
| } |
| return null; |
| } |
| } |
| |
| /// When [addIndicator] is `true`, puts [child] center aligned in a [Stack] with |
| /// a [NavigationIndicator] behind it, otherwise returns [child]. |
| /// |
| /// When [isCircular] is true, the indicator will be a circle, otherwise the |
| /// indicator will be a stadium shape. |
| class _AddIndicator extends StatelessWidget { |
| const _AddIndicator({ |
| required this.addIndicator, |
| required this.isCircular, |
| required this.indicatorColor, |
| required this.indicatorShape, |
| required this.indicatorAnimation, |
| required this.child, |
| }); |
| |
| final bool addIndicator; |
| final bool isCircular; |
| final Color? indicatorColor; |
| final ShapeBorder? indicatorShape; |
| final Animation<double> indicatorAnimation; |
| final Widget child; |
| |
| @override |
| Widget build(BuildContext context) { |
| if (!addIndicator) { |
| return child; |
| } |
| late final Widget indicator; |
| if (isCircular) { |
| indicator = NavigationIndicator( |
| animation: indicatorAnimation, |
| height: _kCircularIndicatorDiameter, |
| width: _kCircularIndicatorDiameter, |
| borderRadius: BorderRadius.circular(_kCircularIndicatorDiameter / 2), |
| color: indicatorColor, |
| ); |
| } else { |
| indicator = NavigationIndicator( |
| animation: indicatorAnimation, |
| width: _kCircularIndicatorDiameter, |
| shape: indicatorShape, |
| color: indicatorColor, |
| ); |
| } |
| |
| return Stack( |
| alignment: Alignment.center, |
| children: <Widget>[ |
| indicator, |
| child, |
| ], |
| ); |
| } |
| } |
| |
| |
| /// Defines the behavior of the labels of a [NavigationRail]. |
| /// |
| /// See also: |
| /// |
| /// * [NavigationRail] |
| enum NavigationRailLabelType { |
| /// Only the [NavigationRailDestination]s are shown. |
| none, |
| |
| /// Only the selected [NavigationRailDestination] will show its label. |
| /// |
| /// The label will animate in and out as new [NavigationRailDestination]s are |
| /// selected. |
| selected, |
| |
| /// All [NavigationRailDestination]s will show their label. |
| all, |
| } |
| |
| /// Defines a [NavigationRail] button that represents one "destination" view. |
| /// |
| /// See also: |
| /// |
| /// * [NavigationRail] |
| class NavigationRailDestination { |
| /// Creates a destination that is used with [NavigationRail.destinations]. |
| /// |
| /// [icon] and [label] must be non-null. When the [NavigationRail.labelType] |
| /// is [NavigationRailLabelType.none], the label is still used for semantics, |
| /// and may still be used if [NavigationRail.extended] is true. |
| const NavigationRailDestination({ |
| required this.icon, |
| Widget? selectedIcon, |
| this.indicatorColor, |
| this.indicatorShape, |
| required this.label, |
| this.padding, |
| this.disabled = false, |
| }) : selectedIcon = selectedIcon ?? icon; |
| |
| /// The icon of the destination. |
| /// |
| /// Typically the icon is an [Icon] or an [ImageIcon] widget. If another type |
| /// of widget is provided then it should configure itself to match the current |
| /// [IconTheme] size and color. |
| /// |
| /// If [selectedIcon] is provided, this will only be displayed when the |
| /// destination is not selected. |
| /// |
| /// To make the [NavigationRail] more accessible, consider choosing an |
| /// icon with a stroked and filled version, such as [Icons.cloud] and |
| /// [Icons.cloud_queue]. The [icon] should be set to the stroked version and |
| /// [selectedIcon] to the filled version. |
| final Widget icon; |
| |
| /// An alternative icon displayed when this destination is selected. |
| /// |
| /// If this icon is not provided, the [NavigationRail] will display [icon] in |
| /// either state. The size, color, and opacity of the |
| /// [NavigationRail.selectedIconTheme] will still apply. |
| /// |
| /// See also: |
| /// |
| /// * [NavigationRailDestination.icon], for a description of how to pair |
| /// icons. |
| final Widget selectedIcon; |
| |
| /// The color of the [indicatorShape] when this destination is selected. |
| final Color? indicatorColor; |
| |
| /// The shape of the selection indicator. |
| final ShapeBorder? indicatorShape; |
| |
| /// The label for the destination. |
| /// |
| /// The label must be provided when used with the [NavigationRail]. When the |
| /// [NavigationRail.labelType] is [NavigationRailLabelType.none], the label is |
| /// still used for semantics, and may still be used if |
| /// [NavigationRail.extended] is true. |
| final Widget label; |
| |
| /// The amount of space to inset the destination item. |
| final EdgeInsetsGeometry? padding; |
| |
| /// Indicates that this destination is inaccessible. |
| final bool disabled; |
| } |
| |
| class _ExtendedNavigationRailAnimation extends InheritedWidget { |
| const _ExtendedNavigationRailAnimation({ |
| required this.animation, |
| required super.child, |
| }); |
| |
| final Animation<double> animation; |
| |
| @override |
| bool updateShouldNotify(_ExtendedNavigationRailAnimation old) => animation != old.animation; |
| } |
| |
| // There don't appear to be tokens for these values, but they are |
| // shown in the spec. |
| const double _horizontalDestinationPadding = 8.0; |
| const double _verticalDestinationPaddingNoLabel = 24.0; |
| const double _verticalDestinationPaddingWithLabel = 16.0; |
| const Widget _verticalSpacer = SizedBox(height: 8.0); |
| const double _verticalIconLabelSpacingM3 = 4.0; |
| const double _verticalDestinationSpacingM3 = 12.0; |
| const double _horizontalDestinationSpacingM3 = 12.0; |
| |
| // Hand coded defaults based on Material Design 2. |
| class _NavigationRailDefaultsM2 extends NavigationRailThemeData { |
| _NavigationRailDefaultsM2(BuildContext context) |
| : _theme = Theme.of(context), |
| _colors = Theme.of(context).colorScheme, |
| super( |
| elevation: 0, |
| groupAlignment: -1, |
| labelType: NavigationRailLabelType.none, |
| useIndicator: false, |
| minWidth: 72.0, |
| minExtendedWidth: 256, |
| ); |
| |
| final ThemeData _theme; |
| final ColorScheme _colors; |
| |
| @override Color? get backgroundColor => _colors.surface; |
| |
| @override TextStyle? get unselectedLabelTextStyle { |
| return _theme.textTheme.bodyLarge!.copyWith(color: _colors.onSurface.withOpacity(0.64)); |
| } |
| |
| @override TextStyle? get selectedLabelTextStyle { |
| return _theme.textTheme.bodyLarge!.copyWith(color: _colors.primary); |
| } |
| |
| @override IconThemeData? get unselectedIconTheme { |
| return IconThemeData( |
| size: 24.0, |
| color: _colors.onSurface, |
| opacity: 0.64, |
| ); |
| } |
| |
| @override IconThemeData? get selectedIconTheme { |
| return IconThemeData( |
| size: 24.0, |
| color: _colors.primary, |
| opacity: 1.0, |
| ); |
| } |
| } |
| |
| // BEGIN GENERATED TOKEN PROPERTIES - NavigationRail |
| |
| // 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 _NavigationRailDefaultsM3 extends NavigationRailThemeData { |
| _NavigationRailDefaultsM3(this.context) |
| : super( |
| elevation: 0.0, |
| groupAlignment: -1, |
| labelType: NavigationRailLabelType.none, |
| useIndicator: true, |
| minWidth: 80.0, |
| minExtendedWidth: 256, |
| ); |
| |
| 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 TextStyle? get unselectedLabelTextStyle { |
| return _textTheme.labelMedium!.copyWith(color: _colors.onSurface); |
| } |
| |
| @override TextStyle? get selectedLabelTextStyle { |
| return _textTheme.labelMedium!.copyWith(color: _colors.onSurface); |
| } |
| |
| @override IconThemeData? get unselectedIconTheme { |
| return IconThemeData( |
| size: 24.0, |
| color: _colors.onSurfaceVariant, |
| ); |
| } |
| |
| @override IconThemeData? get selectedIconTheme { |
| return IconThemeData( |
| size: 24.0, |
| color: _colors.onSecondaryContainer, |
| ); |
| } |
| |
| @override Color? get indicatorColor => _colors.secondaryContainer; |
| |
| @override ShapeBorder? get indicatorShape => const StadiumBorder(); |
| } |
| |
| // END GENERATED TOKEN PROPERTIES - NavigationRail |