diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index c301410376..d530490159 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -99,6 +99,8 @@ export 'src/material/material_localizations.dart'; export 'src/material/material_state.dart'; export 'src/material/material_state_mixin.dart'; export 'src/material/mergeable_material.dart'; +export 'src/material/navigation_bar.dart'; +export 'src/material/navigation_bar_theme.dart'; export 'src/material/navigation_rail.dart'; export 'src/material/navigation_rail_theme.dart'; export 'src/material/no_splash.dart'; diff --git a/packages/flutter/lib/src/material/navigation_bar.dart b/packages/flutter/lib/src/material/navigation_bar.dart new file mode 100644 index 0000000000..b50041608c --- /dev/null +++ b/packages/flutter/lib/src/material/navigation_bar.dart @@ -0,0 +1,1164 @@ +// 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 'elevation_overlay.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'material_state.dart'; +import 'navigation_bar_theme.dart'; +import 'theme.dart'; +import 'tooltip.dart'; + +/// Material 3 Navigation Bar component. +/// +/// Navigation bars offer a persistent and convenient way to switch between +/// primary destinations in an app. +/// +/// This widget does not adjust its size with the [ThemeData.visualDensity]. +/// +/// The [MediaQueryData.textScaleFactor] does not adjust the size of this widget but +/// rather the size of the [Tooltip]s displayed on long presses of the +/// destinations. +/// +/// The style for the icons and text are not affected by parent +/// [DefaultTextStyle]s or [IconTheme]s but rather controlled by parameters or +/// the [NavigationBarThemeData]. +/// +/// This widget holds a collection of destinations (usually +/// [NavigationDestination]s). +/// +/// Usage: +/// ```dart +/// Scaffold( +/// bottomNavigationBar: NavigationBar( +/// onDestinationSelected: (int index) { +/// setState(() { _currentPageIndex = index; }), +/// }, +/// selectedIndex: _currentPageIndex, +/// destinations: [ +/// NavigationDestination( +/// icon: Icon(Icons.explore), +/// label: 'Explore', +/// ), +/// NavigationDestination( +/// icon: Icon(Icons.commute), +/// label: 'Commute', +/// ), +/// NavigationDestination( +/// selectedIcon: Icon(Icons.bookmark), +/// icon: Icon(Icons.bookmark_border), +/// label: 'Saved', +/// ), +/// ], +/// ), +/// ), +/// ``` +class NavigationBar extends StatelessWidget { + /// Creates a Material 3 Navigation Bar component. + /// + /// The value of [destinations] must be a list of two or more + /// [NavigationDestination] values. + const NavigationBar({ + Key? key, + this.animationDuration, + this.selectedIndex = 0, + required this.destinations, + this.onDestinationSelected, + this.backgroundColor, + this.height, + this.labelBehavior, + }) : assert(destinations != null && destinations.length >= 2), + assert(0 <= selectedIndex && selectedIndex < destinations.length), + super(key: key); + + /// Determines the transition time for each destination as it goes between + /// selected and unselected. + final Duration? animationDuration; + + /// Determines which one of the [destinations] is currently selected. + /// + /// When this is updated, the destination (from [destinations]) at + /// [selectedIndex] goes from unselected to selected. + final int selectedIndex; + + /// The list of destinations (usually [NavigationDestination]s) in this + /// [NavigationBar]. + /// + /// When [selectedIndex] is updated, the destination from this list at + /// [selectedIndex] will animate from 0 (unselected) to 1.0 (selected). When + /// the animation is increasing or completed, the destination is considered + /// selected, when the animation is decreasing or dismissed, the destination + /// is considered unselected. + final List destinations; + + /// Called when one of the [destinations] is selected. + /// + /// This callback usually updates the int passed to [selectedIndex]. + /// + /// Upon updating [selectedIndex], the [NavigationBar] will be rebuilt. + final ValueChanged? onDestinationSelected; + + /// The color of the [NavigationBar] itself. + /// + /// If null, [NavigationBarThemeData.backgroundColor] is used. If that + /// is also null, the default blends [ColorScheme.surface] and + /// [ColorScheme.onSurface] using an [ElevationOverlay]. + final Color? backgroundColor; + + /// The height of the [NavigationBar] itself. + /// + /// If this is used in [Scaffold.bottomNavigationBar] and the scaffold is + /// full-screen, the safe area padding is also added to the height + /// automatically. + /// + /// The height does not adjust with [ThemeData.visualDensity] or + /// [MediaQueryData.textScaleFactor] as this component loses usability at + /// larger and smaller sizes due to the truncating of labels or smaller tap + /// targets. + /// + /// If null, [NavigationBarThemeData.height] is used. If that + /// is also null, the default is 80. + final double? height; + + /// Defines how the [destinations]' labels will be laid out and when they'll + /// be displayed. + /// + /// Can be used to show all labels, show only the selected label, or hide all + /// labels. + /// + /// If null, [NavigationBarThemeData.labelBehavior] is used. If that + /// is also null, the default is + /// [NavigationDestinationLabelBehavior.alwaysShow]. + final NavigationDestinationLabelBehavior? labelBehavior; + + VoidCallback _handleTap(int index) { + return onDestinationSelected != null + ? () => onDestinationSelected!(index) + : () {}; + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final NavigationBarThemeData navigationBarTheme = NavigationBarTheme.of(context); + final double effectiveHeight = height ?? navigationBarTheme.height ?? 80; + final NavigationDestinationLabelBehavior effectiveLabelBehavior = labelBehavior + ?? navigationBarTheme.labelBehavior + ?? NavigationDestinationLabelBehavior.alwaysShow; + final double additionalBottomPadding = MediaQuery.of(context).padding.bottom; + + return Material( + // With Material 3, the NavigationBar uses an overlay blend for the + // default color regardless of light/dark mode. + color: backgroundColor + ?? navigationBarTheme.backgroundColor + ?? ElevationOverlay.colorWithOverlay(colorScheme.surface, colorScheme.onSurface, 3.0), + child: Padding( + padding: EdgeInsets.only(bottom: additionalBottomPadding), + child: MediaQuery.removePadding( + context: context, + removeBottom: true, + child: SizedBox( + height: effectiveHeight, + child: Row( + children: [ + for (int i = 0; i < destinations.length; i++) + Expanded( + child: _SelectableAnimatedBuilder( + duration: animationDuration ?? const Duration(milliseconds: 500), + isSelected: i == selectedIndex, + builder: (BuildContext context, Animation animation) { + return _NavigationDestinationInfo( + index: i, + totalNumberOfDestinations: destinations.length, + selectedAnimation: animation, + labelBehavior: effectiveLabelBehavior, + onTap: _handleTap(i), + child: destinations[i], + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Specifies when each [NavigationDestination]'s label should appear. +/// +/// This is used to determine the behavior of [NavigationBar]'s destinations. +enum NavigationDestinationLabelBehavior { + /// Always shows all of the labels under each navigation bar destination, + /// selected and unselected. + alwaysShow, + + /// Never shows any of the labels under the navigation bar destinations, + /// regardless of selected vs unselected. + alwaysHide, + + /// Only shows the labels of the selected navigation bar destination. + /// + /// When a destination is unselected, the label will be faded out, and the + /// icon will be centered. + /// + /// When a destination is selected, the label will fade in and the label and + /// icon will slide up so that they are both centered. + onlyShowSelected, +} + +/// Destination Widget for displaying Icons + labels in the Material 3 +/// Navigation Bars through [NavigationBar.destinations]. +/// +/// The destination this widget creates will look something like this: +/// ======= +/// | +/// | ☆ <-- [icon] (or [selectedIcon]) +/// | text <-- [label] +/// | +/// ======= +class NavigationDestination extends StatelessWidget { + /// Creates a navigation bar destination with an icon and a label, to be used + /// in the [NavigationBar.destinations]. + const NavigationDestination({ + Key? key, + required this.icon, + this.selectedIcon, + required this.label, + this.tooltip, + }) : super(key: key); + + /// The [Widget] (usually an [Icon]) that's displayed for this + /// [NavigationDestination]. + /// + /// The icon will use [NavigationBarThemeData.iconTheme]. If this is + /// null, the default [IconThemeData] would use a size of 24.0 and + /// [ColorScheme.onSurface]. + 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 [NavigationBarThemeData.iconTheme] with + /// [MaterialState.selected]. If this is null, the default [IconThemeData] + /// would use a size of 24.0 and [ColorScheme.onSurface]. + final Widget? selectedIcon; + + /// The text label that appears below the icon of this + /// [NavigationDestination]. + /// + /// The accompanying [Text] widget will use + /// [NavigationBarThemeData.labelTextStyle]. If this are null, the default + /// text style would use [TextTheme.overline] with [ColorScheme.onSurface]. + final String label; + + /// The text to display in the tooltip for this [NavigationDestination], when + /// the user long presses the destination. + /// + /// If [tooltip] is an empty string, no tooltip will be used. + /// + /// Defaults to null, in which case the [label] text will be used. + final String? tooltip; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final NavigationBarThemeData navigationBarTheme = NavigationBarTheme.of(context); + final Animation animation = _NavigationDestinationInfo.of(context).selectedAnimation; + + return _NavigationDestinationBuilder( + label: label, + tooltip: tooltip, + buildIcon: (BuildContext context) { + final IconThemeData defaultIconTheme = IconThemeData( + size: 24, + color: colorScheme.onSurface, + ); + final Widget selectedIconWidget = IconTheme.merge( + data: navigationBarTheme.iconTheme?.resolve({MaterialState.selected}) ?? defaultIconTheme, + child: selectedIcon ?? icon, + ); + final Widget unselectedIconWidget = IconTheme.merge( + data: navigationBarTheme.iconTheme?.resolve({}) ?? defaultIconTheme, + child: icon, + ); + + return Stack( + alignment: Alignment.center, + children: [ + _NavigationIndicator( + animation: animation, + color: navigationBarTheme.indicatorColor, + ), + _StatusTransitionWidgetBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return animation.isForwardOrCompleted + ? selectedIconWidget + : unselectedIconWidget; + }, + ), + ], + ); + }, + buildLabel: (BuildContext context) { + final TextStyle? defaultTextStyle = theme.textTheme.overline?.copyWith( + color: colorScheme.onSurface, + ); + final TextStyle? effectiveSelectedLabelTextStyle = navigationBarTheme.labelTextStyle?.resolve({MaterialState.selected}) ?? defaultTextStyle; + final TextStyle? effectiveUnselectedLabelTextStyle = navigationBarTheme.labelTextStyle?.resolve({}) ?? defaultTextStyle; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: _ClampTextScaleFactor( + // Don't scale labels of destinations, instead, tooltip text will + // upscale. + upperLimit: 1, + child: Text( + label, + style: animation.isForwardOrCompleted + ? effectiveSelectedLabelTextStyle + : effectiveUnselectedLabelTextStyle, + ), + ), + ); + }, + ); + } +} + +/// Widget that handles the semantics and layout of a navigation bar +/// destination. +/// +/// Prefer [NavigationDestination] over this widget, as it is a simpler +/// (although less customizable) way to get navigation bar 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 [_NavigationDestinationInfo.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 [NavigationBar]. + const _NavigationDestinationBuilder({ + Key? key, + required this.buildIcon, + required this.buildLabel, + required this.label, + this.tooltip, + }) : super(key: key); + + /// Builds the icon for an destination in a [NavigationBar]. + /// + /// To animate between unselected and selected, build the icon based on + /// [_NavigationDestinationInfo.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 an destination in a [NavigationBar]. + /// + /// To animate between unselected and selected, build the icon based on + /// [_NavigationDestinationInfo.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; + + /// The text value of what is in the label widget, this is required for + /// semantics so that screen readers and tooltips can read the proper label. + final String label; + + /// The text to display in the tooltip for this [NavigationDestination], when + /// the user long presses the destination. + /// + /// If [tooltip] is an empty string, no tooltip will be used. + /// + /// Defaults to null, in which case the [label] text will be used. + final String? tooltip; + + @override + Widget build(BuildContext context) { + final _NavigationDestinationInfo info = _NavigationDestinationInfo.of(context); + return _NavigationBarDestinationSemantics( + child: _NavigationBarDestinationTooltip( + message: tooltip ?? label, + child: InkWell( + highlightColor: Colors.transparent, + onTap: info.onTap, + child: Row( + children: [ + Expanded( + child: _NavigationBarDestinationLayout( + icon: buildIcon(context), + label: buildLabel(context), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Inherited widget for passing data from the [NavigationBar] to the +/// [NavigationBar.destinations] children widgets. +/// +/// Useful for building navigation destinations using: +/// `_NavigationDestinationInfo.of(context)`. +class _NavigationDestinationInfo extends InheritedWidget { + /// Adds the information needed to build a navigation destination to the + /// [child] and descendants. + const _NavigationDestinationInfo({ + Key? key, + required this.index, + required this.totalNumberOfDestinations, + required this.selectedAnimation, + required this.labelBehavior, + required this.onTap, + required Widget child, + }) : super(key: key, child: child); + + /// Which destination index is this in the navigation bar. + /// + /// For example: + /// ```dart + /// NavigationBar( + /// destinations: [ + /// NavigationDestination(), // This is destination index 0. + /// NavigationDestination(), // This is destination index 1. + /// NavigationDestination(), // This is destination index 2. + /// ] + /// ) + /// ``` + /// + /// 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 are in this navigation bar. + /// + /// 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 selectedAnimation; + + /// Determines the behavior for how the labels will layout. + /// + /// Can be used to show all labels (the default), show only the selected + /// label, or hide all labels. + final NavigationDestinationLabelBehavior labelBehavior; + + /// The callback that should be called when this destination is tapped. + /// + /// This is computed by calling [NavigationBar.onDestinationSelected] + /// with [index] passed in. + final VoidCallback onTap; + + /// Returns a non null [_NavigationDestinationInfo]. + /// + /// This will return an error if called with no [_NavigationDestinationInfo] + /// ancestor. + /// + /// Used by widgets that are implementing a navigation destination info to + /// get information like the selected animation and destination number. + static _NavigationDestinationInfo of(BuildContext context) { + final _NavigationDestinationInfo? result = context.dependOnInheritedWidgetOfExactType<_NavigationDestinationInfo>(); + assert( + result != null, + 'Navigation destinations need a _NavigationDestinationInfo parent, ' + 'which is usually provided by NavigationBar.', + ); + return result!; + } + + @override + bool updateShouldNotify(_NavigationDestinationInfo oldWidget) { + return index != oldWidget.index + || totalNumberOfDestinations != oldWidget.totalNumberOfDestinations + || selectedAnimation != oldWidget.selectedAnimation + || labelBehavior != oldWidget.labelBehavior + || onTap != oldWidget.onTap; + } +} + +/// Selection Indicator for the Material 3 Navigation Bar component. +/// +/// When [animation] is 0, the indicator is not present. As [animation] grows +/// from 0 to 1, the indicator scales in on the x axis. +/// +/// Useful in a [Stack] widget behind the icons in the Material 3 Navigation Bar +/// to illuminate the selected destination. +class _NavigationIndicator extends StatelessWidget { + /// Builds an indicator, usually used in a stack behind the icon of a + /// navigation bar destination. + const _NavigationIndicator({ + Key? key, + required this.animation, + this.color, + }) : super(key: key); + + /// Determines the scale of the indicator. + /// + /// When [animation] is 0, the indicator is not present. The indicator scales + /// in as [animation] grows from 0 to 1. + final Animation animation; + + /// The fill color of this indicator. + /// + /// If null, defaults to [ColorScheme.secondary]. + final Color? color; + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + // The scale should be 0 when the animation is unselected, as soon as + // the animation starts, the scale jumps to 40%, and then animates to + // 100% along a curve. + final double scale = animation.isDismissed + ? 0.0 + : Tween(begin: .4, end: 1.0).transform( + CurveTween(curve: Curves.easeInOutCubicEmphasized).transform(animation.value)); + + return Transform( + alignment: Alignment.center, + // Scale in the X direction only. + transform: Matrix4.diagonal3Values( + scale, + 1.0, + 1.0, + ), + child: child, + ); + }, + // Fade should be a 100ms animation whenever the parent animation changes + // direction. + child: _StatusTransitionWidgetBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return _SelectableAnimatedBuilder( + isSelected: animation.isForwardOrCompleted, + duration: const Duration(milliseconds: 100), + alwaysDoFullAnimation: true, + builder: (BuildContext context, Animation fadeAnimation) { + return FadeTransition( + opacity: fadeAnimation, + child: Container( + width: 64.0, + height: 32.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0), + color: color ?? colorScheme.secondary.withOpacity(.24), + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +/// Widget that handles the layout of the icon + label in a navigation bar +/// destination, based on [_NavigationDestinationInfo.labelBehavior] and +/// [_NavigationDestinationInfo.selectedAnimation]. +/// +/// Depending on the [_NavigationDestinationInfo.labelBehavior], the labels +/// will shift and fade accordingly. +class _NavigationBarDestinationLayout extends StatelessWidget { + /// Builds a widget to layout an icon + label for a destination in a Material + /// 3 [NavigationBar]. + const _NavigationBarDestinationLayout({ + Key? key, + required this.icon, + required this.label, + }) : super(key: key); + + /// The icon widget that sits on top of the label. + /// + /// See [NavigationDestination.icon]. + final Widget icon; + + /// The label widget that sits below the icon. + /// + /// This widget will sometimes be faded out, depending on + /// [_NavigationDestinationInfo.selectedAnimation]. + /// + /// See [NavigationDestination.label]. + final Widget label; + + static final Key _iconKey = UniqueKey(); + static final Key _labelKey = UniqueKey(); + + @override + Widget build(BuildContext context) { + return _DestinationLayoutAnimationBuilder( + builder: (BuildContext context, Animation animation) { + return CustomMultiChildLayout( + delegate: _NavigationDestinationLayoutDelegate( + animation: animation, + ), + children: [ + LayoutId( + id: _NavigationDestinationLayoutDelegate.iconId, + child: RepaintBoundary( + key: _iconKey, + child: icon, + ), + ), + LayoutId( + id: _NavigationDestinationLayoutDelegate.labelId, + child: FadeTransition( + alwaysIncludeSemantics: true, + opacity: animation, + child: RepaintBoundary( + key: _labelKey, + child: label, + ), + ), + ), + ], + ); + }, + ); + } +} + +/// Determines the appropriate [Curve] and [Animation] to use for laying out the +/// [NavigationDestination], based on +/// [_NavigationDestinationInfo.labelBehavior]. +/// +/// The animation controlling the position and fade of the labels differs +/// from the selection animation, depending on the +/// [NavigationDestinationLabelBehavior]. This widget determines what +/// animation should be used for the position and fade of the labels. +class _DestinationLayoutAnimationBuilder extends StatelessWidget { + /// Builds a child with the appropriate animation [Curve] based on the + /// [_NavigationDestinationInfo.labelBehavior]. + const _DestinationLayoutAnimationBuilder({Key? key, required this.builder}) : super(key: key); + + /// Builds the child of this widget. + /// + /// The [Animation] will be the appropriate [Animation] to use for the layout + /// and fade of the [NavigationDestination], either a curve, always + /// showing (1), or always hiding (0). + final Widget Function(BuildContext, Animation) builder; + + @override + Widget build(BuildContext context) { + final _NavigationDestinationInfo info = _NavigationDestinationInfo.of(context); + switch (info.labelBehavior) { + case NavigationDestinationLabelBehavior.alwaysShow: + return builder(context, kAlwaysCompleteAnimation); + case NavigationDestinationLabelBehavior.alwaysHide: + return builder(context, kAlwaysDismissedAnimation); + case NavigationDestinationLabelBehavior.onlyShowSelected: + return _CurvedAnimationBuilder( + animation: info.selectedAnimation, + curve: Curves.easeInOutCubicEmphasized, + reverseCurve: Curves.easeInOutCubicEmphasized.flipped, + builder: (BuildContext context, Animation curvedAnimation) { + return builder(context, curvedAnimation); + }, + ); + } + } +} + +/// Semantics widget for a navigation bar destination. +/// +/// Requires a [_NavigationDestinationInfo] parent (normally provided by the +/// [NavigationBar] by default). +/// +/// Provides localized semantic labels to the destination, for example, it will +/// read "Home, Tab 1 of 3". +/// +/// Used by [_NavigationDestinationBuilder]. +class _NavigationBarDestinationSemantics extends StatelessWidget { + /// Adds the the appropriate semantics for navigation bar destinations to the + /// [child]. + const _NavigationBarDestinationSemantics({ + Key? key, + required this.child, + }) : super(key: key); + + /// The widget that should receive the destination semantics. + final Widget child; + + @override + Widget build(BuildContext context) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final _NavigationDestinationInfo destinationInfo = _NavigationDestinationInfo.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: destinationInfo.selectedAnimation.isForwardOrCompleted, + container: true, + child: child, + ); + }, + child: Stack( + alignment: Alignment.center, + children: [ + child, + Semantics( + label: localizations.tabLabel( + tabIndex: destinationInfo.index + 1, + tabCount: destinationInfo.totalNumberOfDestinations, + ), + ), + ], + ), + ); + } +} + +/// Tooltip widget for use in a [NavigationBar]. +/// +/// It appears just above the navigation bar when one of the destinations is +/// long pressed. +class _NavigationBarDestinationTooltip extends StatelessWidget { + /// Adds a tooltip to the [child] widget. + const _NavigationBarDestinationTooltip({ + Key? key, + required this.message, + required this.child, + }) : super(key: key); + + /// The text that is rendered in the tooltip when it appears. + /// + /// If [message] is null, no tooltip will be used. + final String? message; + + /// The widget that, when pressed, will show a tooltip. + final Widget child; + + @override + Widget build(BuildContext context) { + if (message == null) { + return child; + } + return Tooltip( + message: message!, + // TODO(johnsonmh): Make this value configurable/themable. + verticalOffset: 42, + excludeFromSemantics: true, + preferBelow: false, + child: child, + ); + } +} + +/// Custom layout delegate for shifting navigation bar destinations. +/// +/// This will lay out the icon + label according to the [animation]. +/// +/// When the [animation] is 0, the icon will be centered, and the label will be +/// positioned directly below it. +/// +/// When the [animation] is 1, the label will still be positioned directly below +/// the icon, but the icon + label combination will be centered. +/// +/// Used in a [CustomMultiChildLayout] widget in the +/// [_NavigationDestinationBuilder]. +class _NavigationDestinationLayoutDelegate extends MultiChildLayoutDelegate { + _NavigationDestinationLayoutDelegate({required this.animation}) : super(relayout: animation); + + /// The selection animation that indicates whether or not this destination is + /// selected. + /// + /// See [_NavigationDestinationInfo.selectedAnimation]. + final Animation animation; + + /// ID for the icon widget child. + /// + /// This is used by the [LayoutId] when this delegate is used in a + /// [CustomMultiChildLayout]. + /// + /// See [_NavigationDestinationBuilder]. + static const int iconId = 1; + + /// ID for the label widget child. + /// + /// This is used by the [LayoutId] when this delegate is used in a + /// [CustomMultiChildLayout]. + /// + /// See [_NavigationDestinationBuilder]. + static const int labelId = 2; + + @override + void performLayout(Size size) { + double halfWidth(Size size) => size.width / 2; + double halfHeight(Size size) => size.height / 2; + + final Size iconSize = layoutChild(iconId, BoxConstraints.loose(size)); + final Size labelSize = layoutChild(labelId, BoxConstraints.loose(size)); + + final double yPositionOffset = Tween( + // When unselected, the icon is centered vertically. + begin: halfHeight(iconSize), + // When selected, the icon and label are centered vertically. + end: halfHeight(iconSize) + halfHeight(labelSize), + ).transform(animation.value); + final double iconYPosition = halfHeight(size) - yPositionOffset; + + // Position the icon. + positionChild( + iconId, + Offset( + // Center the icon horizontally. + halfWidth(size) - halfWidth(iconSize), + iconYPosition, + ), + ); + + // Position the label. + positionChild( + labelId, + Offset( + // Center the label horizontally. + halfWidth(size) - halfWidth(labelSize), + // Label always appears directly below the icon. + iconYPosition + iconSize.height, + ), + ); + } + + @override + bool shouldRelayout(_NavigationDestinationLayoutDelegate oldDelegate) { + return oldDelegate.animation != animation; + } +} + +/// Utility Widgets + +/// Clamps [MediaQueryData.textScaleFactor] so that if it is greater than +/// [upperLimit] or less than [lowerLimit], [upperLimit] or [lowerLimit] will be +/// used instead for the [child] widget. +/// +/// Example: +/// ``` +/// _ClampTextScaleFactor( +/// upperLimit: 2.0, +/// child: Text('Foo'), // If textScaleFactor is 3.0, this will only scale 2x. +/// ) +/// ``` +class _ClampTextScaleFactor extends StatelessWidget { + /// Clamps the text scale factor of descendants by modifying the [MediaQuery] + /// surrounding [child]. + const _ClampTextScaleFactor({ + Key? key, + this.lowerLimit = 0, + this.upperLimit = double.infinity, + required this.child, + }) : super(key: key); + + /// The minimum amount that the text scale factor should be for the [child] + /// widget. + /// + /// If this is `.5`, the textScaleFactor for child widgets will never be + /// smaller than `.5`. + final double lowerLimit; + + /// The maximum amount that the text scale factor should be for the [child] + /// widget. + /// + /// If this is `1.5`, the textScaleFactor for child widgets will never be + /// greater than `1.5`. + final double upperLimit; + + /// The [Widget] that should have its (and its descendants) text scale factor + /// clamped. + final Widget child; + + @override + Widget build(BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: MediaQuery.of(context).textScaleFactor.clamp( + lowerLimit, + upperLimit, + ), + ), + child: child, + ); + } +} + +/// 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({ + Key? key, + required Animation animation, + required this.builder, + this.child, + }) : super(animation: animation, key: key); + + /// 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); +} + +/// 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. If [alwaysDoFullAnimation] is +/// true, the animation will reset to 0 or 1 before beginning the animation, so +/// that the full animation is done. +/// +/// 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({ + Key? key, + required this.isSelected, + this.duration = const Duration(milliseconds: 200), + this.alwaysDoFullAnimation = false, + required this.builder, + }) : super(key: key); + + /// 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; + + /// If true, the animation will always go all the way from 0 to 1 when + /// [isSelected] is true, and from 1 to 0 when [isSelected] is false, even + /// when the status changes mid animation. + /// + /// If this is false and the status changes mid animation, the animation will + /// reverse direction from it's current point. + /// + /// Defaults to false. + final bool alwaysDoFullAnimation; + + /// 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) 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(from: widget.alwaysDoFullAnimation ? 0 : null); + } else { + _controller.reverse(from: widget.alwaysDoFullAnimation ? 1 : null); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.builder( + context, + _controller, + ); + } +} + +/// Watches [animation] and calls [builder] with the appropriate [Curve] +/// depending on the direction of the [animation] status. +/// +/// If [animation.status] is forward or complete, [curve] is used. If +/// [animation.status] is reverse or dismissed, [reverseCurve] is used. +/// +/// If the [animation] changes direction while it is already running, the curve +/// used will not change, this will keep the animations smooth until it +/// completes. +/// +/// This is similar to [CurvedAnimation] except the animation status listeners +/// are removed when this widget is disposed. +class _CurvedAnimationBuilder extends StatefulWidget { + const _CurvedAnimationBuilder({ + Key? key, + required this.animation, + required this.curve, + required this.reverseCurve, + required this.builder, + }) : super(key: key); + + final Animation animation; + final Curve curve; + final Curve reverseCurve; + final Widget Function(BuildContext, Animation) builder; + + @override + _CurvedAnimationBuilderState createState() => _CurvedAnimationBuilderState(); +} + +class _CurvedAnimationBuilderState extends State<_CurvedAnimationBuilder> { + late AnimationStatus _animationDirection; + AnimationStatus? _preservedDirection; + + @override + void initState() { + super.initState(); + _animationDirection = widget.animation.status; + _updateStatus(widget.animation.status); + widget.animation.addStatusListener(_updateStatus); + } + + @override + void dispose() { + widget.animation.removeStatusListener(_updateStatus); + super.dispose(); + } + + // Keeps track of the current animation status, as well as the "preserved + // direction" when the animation changes direction mid animation. + // + // The preserved direction is reset when the animation finishes in either + // direction. + void _updateStatus(AnimationStatus status) { + if (_animationDirection != status) { + setState(() { + _animationDirection = status; + }); + } + + if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) { + setState(() { + _preservedDirection = null; + }); + } + + if (_preservedDirection == null && (status == AnimationStatus.forward || status == AnimationStatus.reverse)) { + setState(() { + _preservedDirection = status; + }); + } + } + + @override + Widget build(BuildContext context) { + final bool shouldUseForwardCurve = (_preservedDirection ?? _animationDirection) != AnimationStatus.reverse; + + final Animation curvedAnimation = CurveTween( + curve: shouldUseForwardCurve ? widget.curve : widget.reverseCurve, + ).animate(widget.animation); + + return widget.builder(context, curvedAnimation); + } +} + +/// Convenience static extensions on Animation. +extension _AnimationUtils on Animation { + /// Returns `true` if this animation is ticking forward, or has completed, + /// based on [status]. + bool get isForwardOrCompleted => status == AnimationStatus.forward || status == AnimationStatus.completed; +} diff --git a/packages/flutter/lib/src/material/navigation_bar_theme.dart b/packages/flutter/lib/src/material/navigation_bar_theme.dart new file mode 100644 index 0000000000..9f0d35299b --- /dev/null +++ b/packages/flutter/lib/src/material/navigation_bar_theme.dart @@ -0,0 +1,228 @@ +// 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' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'material_state.dart'; +import 'navigation_bar.dart'; +import 'theme.dart'; + +/// Defines default property values for descendant [NavigationBar] +/// widgets. +/// +/// Descendant widgets obtain the current [NavigationBarThemeData] object +/// using `NavigationBarTheme.of(context)`. Instances of +/// [NavigationBarThemeData] can be customized with +/// [NavigationBarThemeData.copyWith]. +/// +/// Typically a [NavigationBarThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.navigationBarTheme]. Alternatively, a +/// [NavigationBarTheme] inherited widget can be used to theme [NavigationBar]s +/// in a subtree of widgets. +/// +/// All [NavigationBarThemeData] properties are `null` by default. +/// When null, the [NavigationBar] will provide its own defaults based on the +/// overall [Theme]'s textTheme and colorScheme. See the individual +/// [NavigationBar] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class NavigationBarThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.navigationBarTheme] and + /// [NavigationBarTheme]. + const NavigationBarThemeData({ + this.height, + this.backgroundColor, + this.indicatorColor, + this.labelTextStyle, + this.iconTheme, + this.labelBehavior, + }); + + /// Overrides the default value of [NavigationBar.height]. + final double? height; + + /// Overrides the default value of [NavigationBar.backgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value of [NavigationBar]'s selection indicator. + final Color? indicatorColor; + + /// The style to merge with the default text style for + /// [NavigationDestination] labels. + /// + /// You can use this to specify a different style when the label is selected. + final MaterialStateProperty? labelTextStyle; + + /// The theme to merge with the default icon theme for + /// [NavigationDestination] icons. + /// + /// You can use this to specify a different icon theme when the icon is + /// selected. + final MaterialStateProperty? iconTheme; + + /// Overrides the default value of [NavigationBar.labelBehavior]. + final NavigationDestinationLabelBehavior? labelBehavior; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + NavigationBarThemeData copyWith({ + double? height, + Color? backgroundColor, + Color? indicatorColor, + MaterialStateProperty? labelTextStyle, + MaterialStateProperty? iconTheme, + NavigationDestinationLabelBehavior? labelBehavior, + }) { + return NavigationBarThemeData( + height: height ?? this.height, + backgroundColor: backgroundColor ?? this.backgroundColor, + indicatorColor: indicatorColor ?? this.indicatorColor, + labelTextStyle: labelTextStyle ?? this.labelTextStyle, + iconTheme: iconTheme ?? this.iconTheme, + labelBehavior: labelBehavior ?? this.labelBehavior, + ); + } + + /// Linearly interpolate between two navigation rail themes. + /// + /// If both arguments are null then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static NavigationBarThemeData? lerp(NavigationBarThemeData? a, NavigationBarThemeData? b, double t) { + assert(t != null); + if (a == null && b == null) + return null; + return NavigationBarThemeData( + height: lerpDouble(a?.height, b?.height, t), + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + indicatorColor: Color.lerp(a?.indicatorColor, b?.indicatorColor, t), + labelTextStyle: _lerpProperties(a?.labelTextStyle, b?.labelTextStyle, t, TextStyle.lerp), + iconTheme: _lerpProperties(a?.iconTheme, b?.iconTheme, t, IconThemeData.lerp), + labelBehavior: t < 0.5 ? a?.labelBehavior : b?.labelBehavior, + ); + } + + @override + int get hashCode { + return hashValues( + height, + backgroundColor, + indicatorColor, + labelTextStyle, + iconTheme, + labelBehavior, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + return other is NavigationBarThemeData + && other.height == height + && other.backgroundColor == backgroundColor + && other.indicatorColor == indicatorColor + && other.labelTextStyle == labelTextStyle + && other.iconTheme == iconTheme + && other.labelBehavior == labelBehavior; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('height', height, defaultValue: null)); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('indicatorColor', indicatorColor, defaultValue: null)); + properties.add(DiagnosticsProperty>('labelTextStyle', labelTextStyle, defaultValue: null)); + properties.add(DiagnosticsProperty>('iconTheme', iconTheme, defaultValue: null)); + properties.add(DiagnosticsProperty('labelBehavior', labelBehavior, defaultValue: null)); + } + + static MaterialStateProperty? _lerpProperties( + MaterialStateProperty? a, + MaterialStateProperty? b, + double t, + T Function(T?, T?, double) lerpFunction, + ) { + // Avoid creating a _LerpProperties object for a common case. + if (a == null && b == null) + return null; + return _LerpProperties(a, b, t, lerpFunction); + } +} + +class _LerpProperties implements MaterialStateProperty { + const _LerpProperties(this.a, this.b, this.t, this.lerpFunction); + + final MaterialStateProperty? a; + final MaterialStateProperty? b; + final double t; + final T Function(T?, T?, double) lerpFunction; + + @override + T resolve(Set states) { + final T? resolvedA = a?.resolve(states); + final T? resolvedB = b?.resolve(states); + return lerpFunction(resolvedA, resolvedB, t); + } +} + +/// An inherited widget that defines visual properties for [NavigationBar]s and +/// [NavigationDestination]s in this widget's subtree. +/// +/// Values specified here are used for [NavigationBar] properties that are not +/// given an explicit non-null value. +/// +/// See also: +/// +/// * [ThemeData.navigationBarTheme], which describes the +/// [NavigationBarThemeData] in the overall theme for the application. +class NavigationBarTheme extends InheritedTheme { + /// Creates a navigation rail theme that controls the + /// [NavigationBarThemeData] properties for a [NavigationBar]. + /// + /// The data argument must not be null. + const NavigationBarTheme({ + Key? key, + required this.data, + required Widget child, + }) : assert(data != null), super(key: key, child: child); + + /// Specifies the background color, label text style, icon theme, and label + /// type values for descendant [NavigationBar] widgets. + final NavigationBarThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [NavigationBarTheme] widget, then + /// [ThemeData.navigationBarTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// NavigationBarTheme theme = NavigationBarTheme.of(context); + /// ``` + static NavigationBarThemeData of(BuildContext context) { + final NavigationBarTheme? navigationBarTheme = context.dependOnInheritedWidgetOfExactType(); + return navigationBarTheme?.data ?? Theme.of(context).navigationBarTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return NavigationBarTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(NavigationBarTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 6fcdc588d2..d569c507b9 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -28,6 +28,7 @@ import 'floating_action_button_theme.dart'; import 'ink_splash.dart'; import 'ink_well.dart' show InteractiveInkFeatureFactory; import 'input_decorator.dart'; +import 'navigation_bar_theme.dart'; import 'navigation_rail_theme.dart'; import 'outlined_button_theme.dart'; import 'page_transitions_theme.dart'; @@ -317,6 +318,7 @@ class ThemeData with Diagnosticable { ColorScheme? colorScheme, DialogTheme? dialogTheme, FloatingActionButtonThemeData? floatingActionButtonTheme, + NavigationBarThemeData? navigationBarTheme, NavigationRailThemeData? navigationRailTheme, Typography? typography, NoDefaultCupertinoThemeData? cupertinoOverrideTheme, @@ -462,6 +464,7 @@ class ThemeData with Diagnosticable { ); dialogTheme ??= const DialogTheme(); floatingActionButtonTheme ??= const FloatingActionButtonThemeData(); + navigationBarTheme ??= const NavigationBarThemeData(); navigationRailTheme ??= const NavigationRailThemeData(); cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault(); snackBarTheme ??= const SnackBarThemeData(); @@ -543,6 +546,7 @@ class ThemeData with Diagnosticable { colorScheme: colorScheme, dialogTheme: dialogTheme, floatingActionButtonTheme: floatingActionButtonTheme, + navigationBarTheme: navigationBarTheme, navigationRailTheme: navigationRailTheme, typography: typography, cupertinoOverrideTheme: cupertinoOverrideTheme, @@ -677,6 +681,7 @@ class ThemeData with Diagnosticable { required this.colorScheme, required this.dialogTheme, required this.floatingActionButtonTheme, + required this.navigationBarTheme, required this.navigationRailTheme, required this.typography, required this.cupertinoOverrideTheme, @@ -763,6 +768,7 @@ class ThemeData with Diagnosticable { assert(colorScheme != null), assert(dialogTheme != null), assert(floatingActionButtonTheme != null), + assert(navigationBarTheme != null), assert(navigationRailTheme != null), assert(typography != null), assert(snackBarTheme != null), @@ -1269,6 +1275,10 @@ class ThemeData with Diagnosticable { /// [FloatingActionButton]. final FloatingActionButtonThemeData floatingActionButtonTheme; + /// A theme for customizing the background color, text style, and icon themes + /// of a [NavigationBar]. + final NavigationBarThemeData navigationBarTheme; + /// A theme for customizing the background color, elevation, text style, and /// icon themes of a [NavigationRail]. final NavigationRailThemeData navigationRailTheme; @@ -1485,6 +1495,7 @@ class ThemeData with Diagnosticable { ColorScheme? colorScheme, DialogTheme? dialogTheme, FloatingActionButtonThemeData? floatingActionButtonTheme, + NavigationBarThemeData? navigationBarTheme, NavigationRailThemeData? navigationRailTheme, Typography? typography, NoDefaultCupertinoThemeData? cupertinoOverrideTheme, @@ -1576,6 +1587,7 @@ class ThemeData with Diagnosticable { colorScheme: (colorScheme ?? this.colorScheme).copyWith(brightness: brightness), dialogTheme: dialogTheme ?? this.dialogTheme, floatingActionButtonTheme: floatingActionButtonTheme ?? this.floatingActionButtonTheme, + navigationBarTheme: navigationBarTheme ?? this.navigationBarTheme, navigationRailTheme: navigationRailTheme ?? this.navigationRailTheme, typography: typography ?? this.typography, cupertinoOverrideTheme: cupertinoOverrideTheme ?? this.cupertinoOverrideTheme, @@ -1737,6 +1749,7 @@ class ThemeData with Diagnosticable { colorScheme: ColorScheme.lerp(a.colorScheme, b.colorScheme, t), dialogTheme: DialogTheme.lerp(a.dialogTheme, b.dialogTheme, t), floatingActionButtonTheme: FloatingActionButtonThemeData.lerp(a.floatingActionButtonTheme, b.floatingActionButtonTheme, t)!, + navigationBarTheme: NavigationBarThemeData.lerp(a.navigationBarTheme, b.navigationBarTheme, t)!, navigationRailTheme: NavigationRailThemeData.lerp(a.navigationRailTheme, b.navigationRailTheme, t)!, typography: Typography.lerp(a.typography, b.typography, t), cupertinoOverrideTheme: t < 0.5 ? a.cupertinoOverrideTheme : b.cupertinoOverrideTheme, @@ -1826,6 +1839,7 @@ class ThemeData with Diagnosticable { && other.colorScheme == colorScheme && other.dialogTheme == dialogTheme && other.floatingActionButtonTheme == floatingActionButtonTheme + && other.navigationBarTheme == navigationBarTheme && other.navigationRailTheme == navigationRailTheme && other.typography == typography && other.cupertinoOverrideTheme == cupertinoOverrideTheme @@ -1914,6 +1928,7 @@ class ThemeData with Diagnosticable { colorScheme, dialogTheme, floatingActionButtonTheme, + navigationBarTheme, navigationRailTheme, typography, cupertinoOverrideTheme, @@ -1999,6 +2014,7 @@ class ThemeData with Diagnosticable { properties.add(DiagnosticsProperty('colorScheme', colorScheme, defaultValue: defaultData.colorScheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('dialogTheme', dialogTheme, defaultValue: defaultData.dialogTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('floatingActionButtonThemeData', floatingActionButtonTheme, defaultValue: defaultData.floatingActionButtonTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('navigationBarTheme', navigationBarTheme, defaultValue: defaultData.navigationBarTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('navigationRailThemeData', navigationRailTheme, defaultValue: defaultData.navigationRailTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('typography', typography, defaultValue: defaultData.typography, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('cupertinoOverrideTheme', cupertinoOverrideTheme, defaultValue: defaultData.cupertinoOverrideTheme, level: DiagnosticLevel.debug)); diff --git a/packages/flutter/test/material/navigation_bar_test.dart b/packages/flutter/test/material/navigation_bar_test.dart new file mode 100644 index 0000000000..fa6dba2b77 --- /dev/null +++ b/packages/flutter/test/material/navigation_bar_test.dart @@ -0,0 +1,396 @@ +// 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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Navigation bar updates destinations when tapped', (WidgetTester tester) async { + int mutatedIndex = -1; + final Widget widget = _buildWidget( + NavigationBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: Icon(Icons.access_alarm), + label: 'Alarm', + ), + ], + onDestinationSelected: (int i) { + mutatedIndex = i; + }, + ), + ); + + await tester.pumpWidget(widget); + + expect(find.text('AC'), findsOneWidget); + expect(find.text('Alarm'), findsOneWidget); + + await tester.tap(find.text('Alarm')); + expect(mutatedIndex, 1); + + await tester.tap(find.text('AC')); + expect(mutatedIndex, 0); + }); + + testWidgets('NavigationBar can update background color', (WidgetTester tester) async { + const Color color = Colors.yellow; + + await tester.pumpWidget( + _buildWidget( + NavigationBar( + backgroundColor: color, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: Icon(Icons.access_alarm), + label: 'Alarm', + ), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + + expect(_getMaterial(tester).color, equals(color)); + }); + + testWidgets('NavigationBar adds bottom padding to height', (WidgetTester tester) async { + const double bottomPadding = 40.0; + + await tester.pumpWidget( + _buildWidget( + NavigationBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: Icon(Icons.access_alarm), + label: 'Alarm', + ), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + + final double defaultSize = tester.getSize(find.byType(NavigationBar)).height; + expect(defaultSize, 80); + + await tester.pumpWidget( + _buildWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(bottom: bottomPadding)), + child: NavigationBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: Icon(Icons.access_alarm), + label: 'Alarm', + ), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ); + + final double expectedHeight = defaultSize + bottomPadding; + expect(tester.getSize(find.byType(NavigationBar)).height, expectedHeight); + }); + + testWidgets('NavigationBar shows tooltips with text scaling ', (WidgetTester tester) async { + const String label = 'A'; + + Widget buildApp({ required double textScaleFactor }) { + return MediaQuery( + data: MediaQueryData(textScaleFactor: textScaleFactor), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const >[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: NavigationBar( + destinations: const [ + NavigationDestination( + label: label, + icon: Icon(Icons.ac_unit), + tooltip: label, + ), + NavigationDestination( + label: 'B', + icon: Icon(Icons.battery_alert), + ), + ], + ), + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(textScaleFactor: 1.0)); + expect(find.text(label), findsOneWidget); + await tester.longPress(find.text(label)); + expect(find.text(label), findsNWidgets(2)); + + // The default size of a tooltip with the text A. + const Size defaultTooltipSize = Size(14.0, 14.0); + expect(tester.getSize(find.text(label).last), defaultTooltipSize); + // The duration is needed to ensure the tooltip disappears. + await tester.pumpAndSettle(const Duration(seconds: 2)); + + await tester.pumpWidget(buildApp(textScaleFactor: 4.0)); + expect(find.text(label), findsOneWidget); + await tester.longPress(find.text(label)); + expect(tester.getSize(find.text(label).last), Size(defaultTooltipSize.width * 4, defaultTooltipSize.height * 4)); + }); + + testWidgets('Custom tooltips in NavigationBarDestination', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: NavigationBar( + destinations: const [ + NavigationDestination( + label: 'A', + tooltip: 'A tooltip', + icon: Icon(Icons.ac_unit), + ), + NavigationDestination( + label: 'B', + icon: Icon(Icons.battery_alert), + ), + NavigationDestination( + label: 'C', + icon: Icon(Icons.cake), + tooltip: '', + ), + ], + ), + ), + ), + ); + + expect(find.text('A'), findsOneWidget); + await tester.longPress(find.text('A')); + expect(find.byTooltip('A tooltip'), findsOneWidget); + + expect(find.text('B'), findsOneWidget); + await tester.longPress(find.text('B')); + expect(find.byTooltip('B'), findsOneWidget); + + expect(find.text('C'), findsOneWidget); + await tester.longPress(find.text('C')); + expect(find.byTooltip('C'), findsNothing); + }); + + + testWidgets('Navigation bar semantics', (WidgetTester tester) async { + Widget _widget({int selectedIndex = 0}) { + return _buildWidget( + NavigationBar( + selectedIndex: selectedIndex, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: Icon(Icons.access_alarm), + label: 'Alarm', + ), + ], + ), + ); + } + + await tester.pumpWidget(_widget(selectedIndex: 0)); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC\nTab 1 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: true, + hasTapAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm\nTab 2 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: false, + hasTapAction: true, + ), + ); + + await tester.pumpWidget(_widget(selectedIndex: 1)); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC\nTab 1 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: false, + hasTapAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm\nTab 2 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: true, + hasTapAction: true, + ), + ); + }); + + testWidgets('Navigation bar semantics with some labels hidden', (WidgetTester tester) async { + Widget _widget({int selectedIndex = 0}) { + return _buildWidget( + NavigationBar( + labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected, + selectedIndex: selectedIndex, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: Icon(Icons.access_alarm), + label: 'Alarm', + ), + ], + ), + ); + } + + await tester.pumpWidget(_widget(selectedIndex: 0)); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC\nTab 1 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: true, + hasTapAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm\nTab 2 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: false, + hasTapAction: true, + ), + ); + + await tester.pumpWidget(_widget(selectedIndex: 1)); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC\nTab 1 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: false, + hasTapAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm\nTab 2 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: true, + hasTapAction: true, + ), + ); + }); + + testWidgets('Navigation bar does not grow with text scale factor', (WidgetTester tester) async { + const int _animationMilliseconds = 800; + + Widget _widget({double textScaleFactor = 1}) { + return _buildWidget( + MediaQuery( + data: MediaQueryData(textScaleFactor: textScaleFactor), + child: NavigationBar( + animationDuration: const Duration(milliseconds: _animationMilliseconds), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: Icon(Icons.access_alarm), + label: 'Alarm', + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(_widget()); + final double initialHeight = tester.getSize(find.byType(NavigationBar)).height; + + await tester.pumpWidget(_widget(textScaleFactor: 2)); + final double newHeight = tester.getSize(find.byType(NavigationBar)).height; + + expect(newHeight, equals(initialHeight)); + }); +} + +Widget _buildWidget(Widget child) { + return MaterialApp( + theme: ThemeData.light(), + home: Scaffold( + bottomNavigationBar: Center( + child: child, + ), + ), + ); +} + +Material _getMaterial(WidgetTester tester) { + return tester.firstWidget( + find.descendant(of: find.byType(NavigationBar), matching: find.byType(Material)), + ); +} diff --git a/packages/flutter/test/material/navigation_bar_theme_test.dart b/packages/flutter/test/material/navigation_bar_theme_test.dart new file mode 100644 index 0000000000..48c7cb960a --- /dev/null +++ b/packages/flutter/test/material/navigation_bar_theme_test.dart @@ -0,0 +1,254 @@ +// 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/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('copyWith, ==, hashCode basics', () { + expect(const NavigationBarThemeData(), const NavigationBarThemeData().copyWith()); + expect(const NavigationBarThemeData().hashCode, const NavigationBarThemeData().copyWith().hashCode); + }); + + testWidgets('Default debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const NavigationBarThemeData().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('Custom debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + NavigationBarThemeData( + height: 200.0, + backgroundColor: const Color(0x00000099), + indicatorColor: const Color(0x00000098), + labelTextStyle: MaterialStateProperty.all(const TextStyle(fontSize: 7.0)), + iconTheme: MaterialStateProperty.all(const IconThemeData(color: Color(0x00000097))), + labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description[0], 'height: 200.0'); + expect(description[1], 'backgroundColor: Color(0x00000099)'); + expect(description[2], 'indicatorColor: Color(0x00000098)'); + expect(description[3], 'labelTextStyle: MaterialStateProperty.all(TextStyle(inherit: true, size: 7.0))'); + + // Ignore instance address for IconThemeData. + expect(description[4].contains('iconTheme: MaterialStateProperty.all(IconThemeData'), isTrue); + expect(description[4].contains('(color: Color(0x00000097))'), isTrue); + + expect(description[5], 'labelBehavior: NavigationDestinationLabelBehavior.alwaysHide'); + }); + + testWidgets('NavigationBarThemeData values are used when no NavigationBar properties are specified', (WidgetTester tester) async { + const double height = 200.0; + const Color backgroundColor = Color(0x00000001); + const Color indicatorColor = Color(0x00000002); + const double selectedIconSize = 25.0; + const double unselectedIconSize = 23.0; + const Color selectedIconColor = Color(0x00000003); + const Color unselectedIconColor = Color(0x00000004); + const double selectedIconOpacity = 0.99; + const double unselectedIconOpacity = 0.98; + const double selectedLabelFontSize = 13.0; + const double unselectedLabelFontSize = 11.0; + const NavigationDestinationLabelBehavior labelBehavior = NavigationDestinationLabelBehavior.alwaysShow; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: NavigationBarTheme( + data: NavigationBarThemeData( + height: height, + backgroundColor: backgroundColor, + indicatorColor: indicatorColor, + iconTheme: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return const IconThemeData( + size: selectedIconSize, + color: selectedIconColor, + opacity: selectedIconOpacity, + ); + } + return const IconThemeData( + size: unselectedIconSize, + color: unselectedIconColor, + opacity: unselectedIconOpacity, + ); + }), + labelTextStyle: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return const TextStyle(fontSize: selectedLabelFontSize); + } + return const TextStyle(fontSize: unselectedLabelFontSize); + }), + labelBehavior: labelBehavior, + ), + child: NavigationBar( + selectedIndex: 0, + destinations: _destinations(), + ), + ), + ), + ), + ); + + expect(_barHeight(tester), height); + expect(_barMaterial(tester).color, backgroundColor); + expect(_indicator(tester)?.color, indicatorColor); + expect(_selectedIconTheme(tester).size, selectedIconSize); + expect(_selectedIconTheme(tester).color, selectedIconColor); + expect(_selectedIconTheme(tester).opacity, selectedIconOpacity); + expect(_unselectedIconTheme(tester).size, unselectedIconSize); + expect(_unselectedIconTheme(tester).color, unselectedIconColor); + expect(_unselectedIconTheme(tester).opacity, unselectedIconOpacity); + expect(_selectedLabelStyle(tester).fontSize, selectedLabelFontSize); + expect(_unselectedLabelStyle(tester).fontSize, unselectedLabelFontSize); + expect(_labelBehavior(tester), labelBehavior); + }); + + testWidgets('NavigationBar values take priority over NavigationBarThemeData values when both properties are specified', (WidgetTester tester) async { + const double height = 200.0; + const Color backgroundColor = Color(0x00000001); + const NavigationDestinationLabelBehavior labelBehavior = NavigationDestinationLabelBehavior.alwaysShow; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: NavigationBarTheme( + data: const NavigationBarThemeData( + height: 100.0, + backgroundColor: Color(0x00000099), + labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, + ), + child: NavigationBar( + height: height, + backgroundColor: backgroundColor, + labelBehavior: labelBehavior, + selectedIndex: 0, + destinations: _destinations(), + ), + ), + ), + ), + ); + + expect(_barHeight(tester), height); + expect(_barMaterial(tester).color, backgroundColor); + expect(_labelBehavior(tester), labelBehavior); + }); +} + +List _destinations() { + return const [ + NavigationDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: 'Abc', + ), + NavigationDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: 'Def', + ), + ]; +} + +double _barHeight(WidgetTester tester) { + return tester.getRect( + find.byType(NavigationBar), + ).height; +} + +Material _barMaterial(WidgetTester tester) { + return tester.firstWidget( + find.descendant( + of: find.byType(NavigationBar), + matching: find.byType(Material), + ), + ); +} + +BoxDecoration? _indicator(WidgetTester tester) { + return tester.firstWidget( + find.descendant( + of: find.byType(FadeTransition), + matching: find.byType(Container), + ), + ).decoration as BoxDecoration?; +} + +IconThemeData _selectedIconTheme(WidgetTester tester) { + return _iconTheme(tester, Icons.favorite); +} + +IconThemeData _unselectedIconTheme(WidgetTester tester) { + return _iconTheme(tester, Icons.star_border); +} + +IconThemeData _iconTheme(WidgetTester tester, IconData icon) { + return tester.firstWidget( + find.ancestor( + of: find.byIcon(icon), + matching: find.byType(IconTheme), + ), + ).data; +} + +TextStyle _selectedLabelStyle(WidgetTester tester) { + return tester.widget( + find.descendant( + of: find.text('Abc'), + matching: find.byType(RichText), + ), + ).text.style!; +} + +TextStyle _unselectedLabelStyle(WidgetTester tester) { + return tester.widget( + find.descendant( + of: find.text('Def'), + matching: find.byType(RichText), + ), + ).text.style!; +} + +NavigationDestinationLabelBehavior _labelBehavior(WidgetTester tester) { + if (_opacityAboveLabel('Abc').evaluate().isNotEmpty && _opacityAboveLabel('Def').evaluate().isNotEmpty) { + return _labelOpacity(tester, 'Abc') == 1 + ? NavigationDestinationLabelBehavior.onlyShowSelected + : NavigationDestinationLabelBehavior.alwaysHide; + } else { + return NavigationDestinationLabelBehavior.alwaysShow; + } +} + +Finder _opacityAboveLabel(String text) { + return find.ancestor( + of: find.text(text), + matching: find.byType(Opacity), + ); +} + +// Only valid when labelBehavior != alwaysShow. +double _labelOpacity(WidgetTester tester, String text) { + final Opacity opacityWidget = tester.widget( + find.ancestor( + of: find.text(text), + matching: find.byType(Opacity), + ), + ); + return opacityWidget.opacity; +} diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index a8d236167d..915ddc4a26 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -324,6 +324,7 @@ void main() { colorScheme: const ColorScheme.light(), dialogTheme: const DialogTheme(backgroundColor: Colors.black), floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: Colors.black), + navigationBarTheme: const NavigationBarThemeData(backgroundColor: Colors.black), navigationRailTheme: const NavigationRailThemeData(backgroundColor: Colors.black), typography: Typography.material2018(platform: TargetPlatform.android), cupertinoOverrideTheme: null, @@ -420,6 +421,7 @@ void main() { colorScheme: const ColorScheme.light(), dialogTheme: const DialogTheme(backgroundColor: Colors.white), floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: Colors.white), + navigationBarTheme: const NavigationBarThemeData(backgroundColor: Colors.white), navigationRailTheme: const NavigationRailThemeData(backgroundColor: Colors.white), typography: Typography.material2018(platform: TargetPlatform.iOS), cupertinoOverrideTheme: ThemeData.light().cupertinoOverrideTheme, @@ -497,6 +499,7 @@ void main() { colorScheme: otherTheme.colorScheme, dialogTheme: otherTheme.dialogTheme, floatingActionButtonTheme: otherTheme.floatingActionButtonTheme, + navigationBarTheme: otherTheme.navigationBarTheme, navigationRailTheme: otherTheme.navigationRailTheme, typography: otherTheme.typography, cupertinoOverrideTheme: otherTheme.cupertinoOverrideTheme, @@ -571,6 +574,7 @@ void main() { expect(themeDataCopy.colorScheme, equals(otherTheme.colorScheme)); expect(themeDataCopy.dialogTheme, equals(otherTheme.dialogTheme)); expect(themeDataCopy.floatingActionButtonTheme, equals(otherTheme.floatingActionButtonTheme)); + expect(themeDataCopy.navigationBarTheme, equals(otherTheme.navigationBarTheme)); expect(themeDataCopy.navigationRailTheme, equals(otherTheme.navigationRailTheme)); expect(themeDataCopy.typography, equals(otherTheme.typography)); expect(themeDataCopy.cupertinoOverrideTheme, equals(otherTheme.cupertinoOverrideTheme));