From 617ca62709bfa0ce64f6ee90cab2e1e9d46aa173 Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Thu, 21 Feb 2019 16:05:36 -0800 Subject: [PATCH] [Material] Expand BottomNavigationBar API (reprise) (#28159) --- .../demo/material/bottom_navigation_demo.dart | 1 + .../src/material/bottom_navigation_bar.dart | 537 +++++++++++------- .../widgets/bottom_navigation_bar_item.dart | 6 +- .../material/bottom_navigation_bar_test.dart | 528 ++++++++++++++++- 4 files changed, 860 insertions(+), 212 deletions(-) diff --git a/examples/flutter_gallery/lib/demo/material/bottom_navigation_demo.dart b/examples/flutter_gallery/lib/demo/material/bottom_navigation_demo.dart index 2d90b40407..a23e5f0013 100644 --- a/examples/flutter_gallery/lib/demo/material/bottom_navigation_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/bottom_navigation_demo.dart @@ -189,6 +189,7 @@ class _BottomNavigationDemoState extends State .toList(), currentIndex: _currentIndex, type: _type, + //iconSize: 4.0, onTap: (int index) { setState(() { _navigationViews[_currentIndex].controller.reverse(); diff --git a/packages/flutter/lib/src/material/bottom_navigation_bar.dart b/packages/flutter/lib/src/material/bottom_navigation_bar.dart index b23913020c..fb6cb59297 100644 --- a/packages/flutter/lib/src/material/bottom_navigation_bar.dart +++ b/packages/flutter/lib/src/material/bottom_navigation_bar.dart @@ -17,11 +17,6 @@ import 'material_localizations.dart'; import 'text_theme.dart'; import 'theme.dart'; -const double _kActiveFontSize = 14.0; -const double _kInactiveFontSize = 12.0; -const double _kTopMargin = 6.0; -const double _kBottomMargin = 8.0; - /// Defines the layout and behavior of a [BottomNavigationBar]. /// /// See also: @@ -30,18 +25,16 @@ const double _kBottomMargin = 8.0; /// * [BottomNavigationBarItem] /// * enum BottomNavigationBarType { - /// The [BottomNavigationBar]'s [BottomNavigationBarItem]s have fixed width, always - /// display their text labels, and do not shift when tapped. + /// The [BottomNavigationBar]'s [BottomNavigationBarItem]s have fixed width. fixed, /// The location and size of the [BottomNavigationBar] [BottomNavigationBarItem]s - /// animate and labels fade in when they are tapped. Only the selected item - /// displays its text label. + /// animate and labels fade in when they are tapped. shifting, } -/// A material widget displayed at the bottom of an app for selecting among a -/// small number of views, typically between three and five. +/// A material widget that's displayed at the bottom of an app for selecting +/// among a small number of views, typically between three and five. /// /// The bottom navigation bar consists of multiple items in the form of /// text labels, icons, or both, laid out on top of a piece of material. It @@ -52,18 +45,19 @@ enum BottomNavigationBarType { /// where it is provided as the [Scaffold.bottomNavigationBar] argument. /// /// The bottom navigation bar's [type] changes how its [items] are displayed. -/// If not specified it's automatically set to [BottomNavigationBarType.fixed] -/// when there are less than four items, [BottomNavigationBarType.shifting] -/// otherwise. +/// If not specified, then it's automatically set to +/// [BottomNavigationBarType.fixed] when there are less than four items, and +/// [BottomNavigationBarType.shifting] otherwise. /// /// * [BottomNavigationBarType.fixed], the default when there are less than -/// four [items]. The selected item is rendered with [fixedColor] if it's -/// non-null, otherwise the theme's [ThemeData.primaryColor] is used. The -/// navigation bar's background color is the default [Material] background +/// four [items]. The selected item is rendered with the +/// [selectedItemColor] if it's non-null, otherwise the theme's +/// [ThemeData.primaryColor] is used. If [backgroundColor] is null, The +/// navigation bar's background color defaults to the [Material] background /// color, [ThemeData.canvasColor] (essentially opaque white). /// * [BottomNavigationBarType.shifting], the default when there are four -/// or more [items]. All items are rendered in white and the navigation bar's -/// background color is the same as the +/// or more [items]. If [selectedItemColor] is null, all items are rendered +/// in white. The navigation bar's background color is the same as the /// [BottomNavigationBarItem.backgroundColor] of the selected item. In this /// case it's assumed that each item will have a different background color /// and that background color will contrast well with white. @@ -71,10 +65,9 @@ enum BottomNavigationBarType { /// {@tool snippet --template=stateful_widget_material} /// This example shows a [BottomNavigationBar] as it is used within a [Scaffold] /// widget. The [BottomNavigationBar] has three [BottomNavigationBarItem] -/// widgets and the [currentIndex] is set to index 1. The color of the selected -/// item is set to a purple color. A function is called whenever any item is -/// tapped and the function helps display the appropriate [Text] in the body of -/// the [Scaffold]. +/// widgets and the [currentIndex] is set to index 1. The selected item is +/// purple. The `_onItemTapped` function changes the selected item's index +/// and displays a corresponding message in the center of the [Scaffold]. /// /// ```dart /// int _selectedIndex = 1; @@ -106,7 +99,7 @@ enum BottomNavigationBarType { /// BottomNavigationBarItem(icon: Icon(Icons.school), title: Text('School')), /// ], /// currentIndex: _selectedIndex, -/// fixedColor: Colors.deepPurple, +/// selectedItemColor: Colors.deepPurple, /// onTap: _onItemTapped, /// ), /// ); @@ -120,25 +113,43 @@ enum BottomNavigationBarType { /// * [Scaffold] /// * class BottomNavigationBar extends StatefulWidget { - /// Creates a bottom navigation bar, typically used in a [Scaffold] where it - /// is provided as the [Scaffold.bottomNavigationBar] argument. + /// Creates a bottom navigation bar which is typically used as a + /// [Scaffold]'s [Scaffold.bottomNavigationBar] argument. /// - /// The length of [items] must be at least two and each item's icon and title must be not null. + /// The length of [items] must be at least two and each item's icon and title + /// must not be null. /// /// If [type] is null then [BottomNavigationBarType.fixed] is used when there /// are two or three [items], [BottomNavigationBarType.shifting] otherwise. /// - /// If [fixedColor] is null then the theme's primary color, - /// [ThemeData.primaryColor], is used. However if [BottomNavigationBar.type] is - /// [BottomNavigationBarType.shifting] then [fixedColor] is ignored. + /// The [iconSize], [selectedFontSize], [unselectedFontSize], and [elevation] + /// arguments must be non-null and non-negative. + /// + /// Only one of [selectedItemColor] and [fixedColor] can be specified. The + /// former is preferred, [fixedColor] only exists for the sake of + /// backwards compatibility. + /// + /// The [showSelectedLabels] argument must not be non-null. + /// + /// The [showUnselectedLabels] argument defaults to `true` if [type] is + /// [BottomNavigationBarType.fixed] and `false` if [type] is + /// [BottomNavigationBarType.shifting]. BottomNavigationBar({ Key key, @required this.items, this.onTap, this.currentIndex = 0, + this.elevation = 8.0, BottomNavigationBarType type, - this.fixedColor, + Color fixedColor, + this.backgroundColor, this.iconSize = 24.0, + Color selectedItemColor, + this.unselectedItemColor, + this.selectedFontSize = 14.0, + this.unselectedFontSize = 12.0, + this.showSelectedLabels = true, + bool showUnselectedLabels, }) : assert(items != null), assert(items.length >= 2), assert( @@ -146,42 +157,125 @@ class BottomNavigationBar extends StatefulWidget { 'Every item must have a non-null title', ), assert(0 <= currentIndex && currentIndex < items.length), - assert(iconSize != null), - type = type ?? (items.length <= 3 ? BottomNavigationBarType.fixed : BottomNavigationBarType.shifting), + assert(elevation != null && elevation >= 0.0), + assert(iconSize != null && iconSize >= 0.0), + assert( + selectedItemColor != null ? fixedColor == null : true, + 'Either selectedItemColor or fixedColor can be specified, but not both' + ), + assert(selectedFontSize != null && selectedFontSize >= 0.0), + assert(unselectedFontSize != null && unselectedFontSize >= 0.0), + assert(showSelectedLabels != null), + type = _type(type, items), + selectedItemColor = selectedItemColor ?? fixedColor, + showUnselectedLabels = showUnselectedLabels ?? _defaultShowUnselected(_type(type, items)), super(key: key); - /// The interactive items laid out within the bottom navigation bar where each item has an icon and title. + /// Defines the appearance of the button items that are arrayed within the + /// bottom navigation bar. final List items; - /// The callback that is called when a item is tapped. + /// Called when one of the [items] is tapped. /// - /// The widget creating the bottom navigation bar needs to keep track of the - /// current index and call `setState` to rebuild it with the newly provided - /// index. + /// The stateful widget that creates the bottom navigation bar needs to keep + /// track of the index of the selected [BottomNavigationBarItem] and call + /// `setState` to rebuild the bottom navigation bar with the new [currentIndex]. final ValueChanged onTap; - /// The index into [items] of the current active item. + /// The index into [items] for the current active [BottomNavigationBarItem]. final int currentIndex; + /// The z-coordinate of this [BottomNavigationBar]. + /// + /// If null, defaults to `8.0`. + /// + /// {@macro flutter.material.material.elevation} + final double elevation; + /// Defines the layout and behavior of a [BottomNavigationBar]. /// - /// See documentation for [BottomNavigationBarType] for information on the meaning - /// of different types. + /// See documentation for [BottomNavigationBarType] for information on the + /// meaning of different types. final BottomNavigationBarType type; - /// The color of the selected item when bottom navigation bar is - /// [BottomNavigationBarType.fixed]. + /// The value of [selectedItemColor]. /// - /// If [fixedColor] is null then the theme's primary color, - /// [ThemeData.primaryColor], is used. However if [BottomNavigationBar.type] is - /// [BottomNavigationBarType.shifting] then [fixedColor] is ignored. - final Color fixedColor; + /// This getter only exists for backwards compatibility, the + /// [selectedItemColor] property is preferred. + Color get fixedColor => selectedItemColor; + + /// The color of the [BottomNavigationBar] itself. + /// + /// If [type] is [BottomNavigationBarType.shifting] and the + /// [items]s, have [BottomNavigationBarItem.backgroundColor] set, the [item]'s + /// backgroundColor will splash and overwrite this color. + final Color backgroundColor; /// The size of all of the [BottomNavigationBarItem] icons. /// /// See [BottomNavigationBarItem.icon] for more information. final double iconSize; + /// The color of the selected [BottomNavigationBarItem.icon] and + /// [BottomNavigationBarItem.label]. + /// + /// If null then the [ThemeData.primaryColor] is used. + final Color selectedItemColor; + + /// The color of the unselected [BottomNavigationBarItem.icon] and + /// [BottomNavigationBarItem.label]s. + /// + /// If null then the [TextTheme.caption]'s color is used. + final Color unselectedItemColor; + + /// The font size of the [BottomNavigationBarItem] labels when they are selected. + /// + /// Defaults to `14.0`. + final double selectedFontSize; + + /// The font size of the [BottomNavigationBarItem] labels when they are not + /// selected. + /// + /// Defaults to `12.0`. + final double unselectedFontSize; + + /// Whether the labels are shown for the selected [BottomNavigationBarItem]. + final bool showUnselectedLabels; + + /// Whether the labels are shown for the unselected [BottomNavigationBarItem]s. + final bool showSelectedLabels; + + // Used by the [BottomNavigationBar] constructor to set the [type] parameter. + // + // If type is provided, it is returned. Otherwise, + // [BottomNavigationBarType.fixed] is used for 3 or fewer items, and + // [BottomNavigationBarType.shifting] is used for 4+ items. + static BottomNavigationBarType _type( + BottomNavigationBarType type, + List items, + ) { + if (type != null) { + return type; + } + return items.length <= 3 ? BottomNavigationBarType.fixed : BottomNavigationBarType.shifting; + } + + // Used by the [BottomNavigationBar] constructor to set the [showUnselected] + // parameter. + // + // Unselected labels are shown by default for [BottomNavigationBarType.fixed], + // and hidden by default for [BottomNavigationBarType.shifting]. + static bool _defaultShowUnselected(BottomNavigationBarType type) { + switch (type) { + case BottomNavigationBarType.shifting: + return false; + case BottomNavigationBarType.fixed: + return true; + } + assert(false); + return false; + } + @override _BottomNavigationBarState createState() => _BottomNavigationBarState(); } @@ -198,8 +292,17 @@ class _BottomNavigationTile extends StatelessWidget { this.colorTween, this.flex, this.selected = false, + @required this.selectedFontSize, + @required this.unselectedFontSize, + this.showSelectedLabels, + this.showUnselectedLabels, this.indexLabel, - }) : assert(selected != null); + }) : assert(type != null), + assert(item != null), + assert(animation != null), + assert(selected != null), + assert(selectedFontSize != null && selectedFontSize >= 0), + assert(unselectedFontSize != null && unselectedFontSize >= 0); final BottomNavigationBarType type; final BottomNavigationBarItem item; @@ -209,7 +312,11 @@ class _BottomNavigationTile extends StatelessWidget { final ColorTween colorTween; final double flex; final bool selected; + final double selectedFontSize; + final double unselectedFontSize; final String indexLabel; + final bool showSelectedLabels; + final bool showUnselectedLabels; @override Widget build(BuildContext context) { @@ -218,16 +325,50 @@ class _BottomNavigationTile extends StatelessWidget { // produce smooth animation. We do this by multiplying the flex value // (which is an integer) by a large number. int size; - Widget label; + + double bottomPadding = selectedFontSize / 2.0; + double topPadding = selectedFontSize / 2.0; + + // Defines the padding for the animating icons + labels. + // + // The animations go from "Unselected": + // ======= + // | <-- Padding equal to the text height. + // | ☆ + // | text <-- Invisible text. + // ======= + // + // To "Selected": + // + // ======= + // | <-- Padding equal to 1/2 text height. + // | ☆ + // | text + // | <-- Padding equal to 1/2 text height. + // ======= + if (showSelectedLabels && !showUnselectedLabels) { + bottomPadding = Tween( + begin: 0.0, + end: selectedFontSize / 2.0, + ).evaluate(animation); + topPadding = Tween( + begin: selectedFontSize, + end: selectedFontSize / 2.0, + ).evaluate(animation); + } + + // Center all icons if no labels are shown. + if (!showSelectedLabels && !showUnselectedLabels) { + bottomPadding = 0.0; + topPadding = selectedFontSize; + } switch (type) { case BottomNavigationBarType.fixed: size = 1; - label = _FixedLabel(colorTween: colorTween, animation: animation, item: item); break; case BottomNavigationBarType.shifting: size = (flex * 1000.0).round(); - label = _ShiftingLabel(animation: animation, item: item); break; } @@ -241,21 +382,31 @@ class _BottomNavigationTile extends StatelessWidget { children: [ InkResponse( onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.min, - children: [ - _TileIcon( - type: type, - colorTween: colorTween, - animation: animation, - iconSize: iconSize, - selected: selected, - item: item, - ), - label, - ], + child: Padding( + padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + _TileIcon( + colorTween: colorTween, + animation: animation, + iconSize: iconSize, + selected: selected, + item: item, + ), + _Label( + colorTween: colorTween, + animation: animation, + item: item, + selectedFontSize: selectedFontSize, + unselectedFontSize: unselectedFontSize, + showSelectedLabels: showSelectedLabels, + showUnselectedLabels: showUnselectedLabels, + ), + ], + ), ), ), Semantics( @@ -272,15 +423,15 @@ class _BottomNavigationTile extends StatelessWidget { class _TileIcon extends StatelessWidget { const _TileIcon({ Key key, - @required this.type, @required this.colorTween, @required this.animation, @required this.iconSize, @required this.selected, @required this.item, - }) : super(key: key); + }) : assert(selected != null), + assert(item != null), + super(key: key); - final BottomNavigationBarType type; final ColorTween colorTween; final Animation animation; final double iconSize; @@ -289,28 +440,11 @@ class _TileIcon extends StatelessWidget { @override Widget build(BuildContext context) { - double tweenStart; - Color iconColor; - switch (type) { - case BottomNavigationBarType.fixed: - tweenStart = 8.0; - iconColor = colorTween.evaluate(animation); - break; - case BottomNavigationBarType.shifting: - tweenStart = 16.0; - iconColor = Colors.white; - break; - } + final Color iconColor = colorTween.evaluate(animation); return Align( alignment: Alignment.topCenter, heightFactor: 1.0, child: Container( - margin: EdgeInsets.only( - top: Tween( - begin: tweenStart, - end: _kTopMargin, - ).evaluate(animation), - ), child: IconTheme( data: IconThemeData( color: iconColor, @@ -323,89 +457,84 @@ class _TileIcon extends StatelessWidget { } } -class _FixedLabel extends StatelessWidget { - const _FixedLabel({ +class _Label extends StatelessWidget { + const _Label({ Key key, @required this.colorTween, @required this.animation, @required this.item, - }) : super(key: key); + @required this.selectedFontSize, + @required this.unselectedFontSize, + @required this.showSelectedLabels, + @required this.showUnselectedLabels, + }) : assert(colorTween != null), + assert(animation != null), + assert(item != null), + assert(selectedFontSize != null), + assert(unselectedFontSize != null), + assert(showSelectedLabels != null), + assert(showUnselectedLabels != null), + super(key: key); final ColorTween colorTween; final Animation animation; final BottomNavigationBarItem item; + final double selectedFontSize; + final double unselectedFontSize; + final bool showSelectedLabels; + final bool showUnselectedLabels; @override Widget build(BuildContext context) { - return Align( - alignment: Alignment.bottomCenter, - heightFactor: 1.0, - child: Container( - margin: const EdgeInsets.only(bottom: _kBottomMargin), - child: DefaultTextStyle.merge( - style: TextStyle( - fontSize: _kActiveFontSize, - color: colorTween.evaluate(animation), - ), - // The font size should grow here when active, but because of the way - // font rendering works, it doesn't grow smoothly if we just animate - // the font size, so we use a transform instead. - child: Transform( - transform: Matrix4.diagonal3( - Vector3.all( - Tween( - begin: _kInactiveFontSize / _kActiveFontSize, - end: 1.0, - ).evaluate(animation), - ), - ), - alignment: Alignment.bottomCenter, - child: item.title, + Widget text = DefaultTextStyle.merge( + style: TextStyle( + fontSize: selectedFontSize, + color: colorTween.evaluate(animation), + ), + // The font size should grow here when active, but because of the way + // font rendering works, it doesn't grow smoothly if we just animate + // the font size, so we use a transform instead. + child: Transform( + transform: Matrix4.diagonal3( + Vector3.all( + Tween( + begin: unselectedFontSize / selectedFontSize, + end: 1.0, + ).evaluate(animation), ), ), + alignment: Alignment.bottomCenter, + child: item.title, ), ); - } -} -class _ShiftingLabel extends StatelessWidget { - const _ShiftingLabel({ - Key key, - @required this.animation, - @required this.item, - }) : super(key: key); + if (!showUnselectedLabels && !showSelectedLabels) { + // Never show any labels. + text = Opacity( + alwaysIncludeSemantics: true, + opacity: 0.0, + child: text, + ); + } else if (!showUnselectedLabels) { + // Fade selected labels in. + text = FadeTransition( + alwaysIncludeSemantics: true, + opacity: animation, + child: text, + ); + } else if (!showSelectedLabels) { + // Fade selected labels out. + text = FadeTransition( + alwaysIncludeSemantics: true, + opacity: Tween(begin: 1.0, end: 0.0).animate(animation), + child: text, + ); + } - final Animation animation; - final BottomNavigationBarItem item; - - @override - Widget build(BuildContext context) { return Align( alignment: Alignment.bottomCenter, heightFactor: 1.0, - child: Container( - margin: EdgeInsets.only( - bottom: Tween( - // In the spec, they just remove the label for inactive items and - // specify a 16dp bottom margin. We don't want to actually remove - // the label because we want to fade it in and out, so this modifies - // the bottom margin to take that into account. - begin: 2.0, - end: _kBottomMargin, - ).evaluate(animation), - ), - child: FadeTransition( - alwaysIncludeSemantics: true, - opacity: animation, - child: DefaultTextStyle.merge( - style: const TextStyle( - fontSize: _kActiveFontSize, - color: Colors.white, - ), - child: item.title, - ), - ), - ), + child: Container(child: text), ); } } @@ -529,63 +658,57 @@ class _BottomNavigationBarState extends State with TickerPr List _createTiles() { final MaterialLocalizations localizations = MaterialLocalizations.of(context); assert(localizations != null); - final List children = []; - switch (widget.type) { - case BottomNavigationBarType.fixed: - final ThemeData themeData = Theme.of(context); - final TextTheme textTheme = themeData.textTheme; - Color themeColor; - switch (themeData.brightness) { - case Brightness.light: - themeColor = themeData.primaryColor; - break; - case Brightness.dark: - themeColor = themeData.accentColor; - break; - } - final ColorTween colorTween = ColorTween( - begin: textTheme.caption.color, - end: widget.fixedColor ?? themeColor, - ); - for (int i = 0; i < widget.items.length; i += 1) { - children.add( - _BottomNavigationTile( - widget.type, - widget.items[i], - _animations[i], - widget.iconSize, - onTap: () { - if (widget.onTap != null) - widget.onTap(i); - }, - colorTween: colorTween, - selected: i == widget.currentIndex, - indexLabel: localizations.tabLabel(tabIndex: i + 1, tabCount: widget.items.length), - ), - ); - } + + final ThemeData themeData = Theme.of(context); + + Color themeColor; + switch (themeData.brightness) { + case Brightness.light: + themeColor = themeData.primaryColor; break; - case BottomNavigationBarType.shifting: - for (int i = 0; i < widget.items.length; i += 1) { - children.add( - _BottomNavigationTile( - widget.type, - widget.items[i], - _animations[i], - widget.iconSize, - onTap: () { - if (widget.onTap != null) - widget.onTap(i); - }, - flex: _evaluateFlex(_animations[i]), - selected: i == widget.currentIndex, - indexLabel: localizations.tabLabel(tabIndex: i + 1, tabCount: widget.items.length), - ), - ); - } + case Brightness.dark: + themeColor = themeData.accentColor; break; } - return children; + + ColorTween colorTween; + switch (widget.type) { + case BottomNavigationBarType.fixed: + colorTween = ColorTween( + begin: widget.unselectedItemColor ?? themeData.textTheme.caption.color, + end: widget.selectedItemColor ?? widget.fixedColor ?? themeColor, + ); + break; + case BottomNavigationBarType.shifting: + colorTween = ColorTween( + begin: widget.unselectedItemColor ?? Colors.white, + end: widget.selectedItemColor ?? Colors.white, + ); + break; + } + + final List tiles = []; + for (int i = 0; i < widget.items.length; i++) { + tiles.add(_BottomNavigationTile( + widget.type, + widget.items[i], + _animations[i], + widget.iconSize, + selectedFontSize: widget.selectedFontSize, + unselectedFontSize: widget.unselectedFontSize, + onTap: () { + if (widget.onTap != null) + widget.onTap(i); + }, + colorTween: colorTween, + flex: _evaluateFlex(_animations[i]), + selected: i == widget.currentIndex, + showSelectedLabels: widget.showSelectedLabels, + showUnselectedLabels: widget.showUnselectedLabels, + indexLabel: localizations.tabLabel(tabIndex: i + 1, tabCount: widget.items.length), + )); + } + return tiles; } Widget _createContainer(List tiles) { @@ -602,12 +725,14 @@ class _BottomNavigationBarState extends State with TickerPr Widget build(BuildContext context) { assert(debugCheckHasDirectionality(context)); assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasMediaQuery(context)); // Labels apply up to _bottomMargin padding. Remainder is media padding. - final double additionalBottomPadding = math.max(MediaQuery.of(context).padding.bottom - _kBottomMargin, 0.0); + final double additionalBottomPadding = math.max(MediaQuery.of(context).padding.bottom - widget.selectedFontSize / 2.0, 0.0); Color backgroundColor; switch (widget.type) { case BottomNavigationBarType.fixed: + backgroundColor = widget.backgroundColor; break; case BottomNavigationBarType.shifting: backgroundColor = _backgroundColor; @@ -616,7 +741,7 @@ class _BottomNavigationBarState extends State with TickerPr return Semantics( explicitChildNodes: true, child: Material( - elevation: 8.0, + elevation: widget.elevation, color: backgroundColor, child: ConstrainedBox( constraints: BoxConstraints(minHeight: kBottomNavigationBarHeight + additionalBottomPadding), diff --git a/packages/flutter/lib/src/widgets/bottom_navigation_bar_item.dart b/packages/flutter/lib/src/widgets/bottom_navigation_bar_item.dart index 6a266d6d6d..e93e78e335 100644 --- a/packages/flutter/lib/src/widgets/bottom_navigation_bar_item.dart +++ b/packages/flutter/lib/src/widgets/bottom_navigation_bar_item.dart @@ -9,8 +9,8 @@ import 'framework.dart'; /// An interactive button within either material's [BottomNavigationBar] /// or the iOS themed [CupertinoTabBar] with an icon and title. /// -/// This class is rarely used in isolation. Commonly embedded in one of the -/// bottom navigation widgets above. +/// This class is rarely used in isolation. It is typically embedded in one of +/// the bottom navigation widgets above. /// /// See also: /// @@ -67,7 +67,7 @@ class BottomNavigationBarItem { /// /// If the navigation bar's type is [BottomNavigationBarType.shifting], then /// the entire bar is flooded with the [backgroundColor] when this item is - /// tapped. + /// tapped. This will override [BottomNavigationBar.backgroundColor]. /// /// Not used for [CupertinoTabBar]. Control the invariant bar color directly /// via [CupertinoTabBar.backgroundColor]. diff --git a/packages/flutter/test/material/bottom_navigation_bar_test.dart b/packages/flutter/test/material/bottom_navigation_bar_test.dart index 7f387426ba..d2312a5a1c 100644 --- a/packages/flutter/test/material/bottom_navigation_bar_test.dart +++ b/packages/flutter/test/material/bottom_navigation_bar_test.dart @@ -6,7 +6,9 @@ import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math_64.dart' show Vector3; import '../rendering/mock_canvas.dart'; import '../widgets/semantics_tester.dart'; @@ -68,6 +70,325 @@ void main() { expect(find.text('Alarm'), findsOneWidget); }); + testWidgets('Fixed BottomNavigationBar defaults', (WidgetTester tester) async { + const Color primaryColor = Colors.black; + const Color captionColor = Colors.purple; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + primaryColor: primaryColor, + textTheme: const TextTheme(caption: TextStyle(color: captionColor)), + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('AC'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Alarm'), + ), + ] + ) + ) + ) + ); + + const double selectedFontSize = 14.0; + const double unselectedFontSize = 12.0; + expect(tester.renderObject(find.text('AC')).text.style.fontSize, selectedFontSize); + // Unselected label has a font size of 14 but is scaled down to be font size 12. + expect(tester.renderObject(find.text('Alarm')).text.style.fontSize, selectedFontSize); + expect( + tester.firstWidget(find.ancestor(of: find.text('Alarm'), matching: find.byType(Transform))).transform, + equals(Matrix4.diagonal3(Vector3.all(unselectedFontSize / selectedFontSize))), + ); + expect(tester.renderObject(find.text('AC')).text.style.color, equals(primaryColor)); + expect(tester.renderObject(find.text('Alarm')).text.style.color, equals(captionColor)); + expect(_getOpacity(tester, 'Alarm'), equals(1.0)); + expect(_getMaterial(tester).elevation, equals(8.0)); + }); + + testWidgets('Shifting BottomNavigationBar defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('AC'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Alarm'), + ), + ] + ) + ) + ) + ); + + const double selectedFontSize = 14.0; + expect(tester.renderObject(find.text('AC')).text.style.fontSize, selectedFontSize); + expect(tester.renderObject(find.text('AC')).text.style.color, equals(Colors.white)); + expect(_getOpacity(tester, 'Alarm'), equals(0.0)); + expect(_getMaterial(tester).elevation, equals(8.0)); + }); + + testWidgets('Fixed BottomNavigationBar custom font size, color', (WidgetTester tester) async { + const Color primaryColor = Colors.black; + const Color captionColor = Colors.purple; + const Color selectedColor = Colors.blue; + const Color unselectedColor = Colors.yellow; + const double selectedFontSize = 18.0; + const double unselectedFontSize = 14.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + primaryColor: primaryColor, + textTheme: const TextTheme(caption: TextStyle(color: captionColor)), + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + selectedFontSize: selectedFontSize, + unselectedFontSize: unselectedFontSize, + selectedItemColor: selectedColor, + unselectedItemColor: unselectedColor, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('AC'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Alarm'), + ), + ] + ) + ) + ) + ); + + expect(tester.renderObject(find.text('AC')).text.style.fontSize, selectedFontSize); + // Unselected label has a font size of 18 but is scaled down to be font size 14. + expect(tester.renderObject(find.text('Alarm')).text.style.fontSize, selectedFontSize); + expect( + tester.firstWidget(find.ancestor(of: find.text('Alarm'), matching: find.byType(Transform))).transform, + equals(Matrix4.diagonal3(Vector3.all(unselectedFontSize / selectedFontSize))), + ); + expect(tester.renderObject(find.text('AC')).text.style.color, equals(selectedColor)); + expect(tester.renderObject(find.text('Alarm')).text.style.color, equals(unselectedColor)); + expect(_getOpacity(tester, 'Alarm'), equals(1.0)); + }); + + + testWidgets('Shifting BottomNavigationBar custom font size, color', (WidgetTester tester) async { + const Color primaryColor = Colors.black; + const Color captionColor = Colors.purple; + const Color selectedColor = Colors.blue; + const Color unselectedColor = Colors.yellow; + const double selectedFontSize = 18.0; + const double unselectedFontSize = 14.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + primaryColor: primaryColor, + textTheme: const TextTheme(caption: TextStyle(color: captionColor)), + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + selectedFontSize: selectedFontSize, + unselectedFontSize: unselectedFontSize, + selectedItemColor: selectedColor, + unselectedItemColor: unselectedColor, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('AC'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Alarm'), + ), + ] + ) + ) + ) + ); + + expect(tester.renderObject(find.text('AC')).text.style.fontSize, selectedFontSize); + expect(tester.renderObject(find.text('AC')).text.style.color, equals(selectedColor)); + expect(_getOpacity(tester, 'Alarm'), equals(0.0)); + }); + + testWidgets('Fixed BottomNavigationBar can hide unselected labels', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + showUnselectedLabels: false, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('AC'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Alarm'), + ), + ] + ) + ) + ) + ); + + expect(_getOpacity(tester, 'AC'), equals(1.0)); + expect(_getOpacity(tester, 'Alarm'), equals(0.0)); + }); + + testWidgets('Fixed BottomNavigationBar can update background color', (WidgetTester tester) async { + const Color color = Colors.yellow; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + backgroundColor: color, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('AC'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Alarm'), + ), + ] + ) + ) + ) + ); + + expect(_getMaterial(tester).color, equals(color)); + }); + + testWidgets('Shifting BottomNavigationBar background color is overriden by item color', (WidgetTester tester) async { + const Color itemColor = Colors.yellow; + const Color backgroundColor = Colors.blue; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + backgroundColor: backgroundColor, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('AC'), + backgroundColor: itemColor, + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Alarm'), + ), + ] + ) + ) + ) + ); + + expect(_getMaterial(tester).color, equals(itemColor)); + }); + + testWidgets('Specifying both selectedItemColor and fixedColor asserts', (WidgetTester tester) async { + expect( + () { + return BottomNavigationBar( + selectedItemColor: Colors.black, + fixedColor: Colors.black, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('AC'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Alarm'), + ), + ], + ); + }, + throwsAssertionError, + ); + }); + + testWidgets('Fixed BottomNavigationBar uses fixedColor when selectedItemColor not provided', (WidgetTester tester) async { + const Color fixedColor = Colors.black; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + fixedColor: fixedColor, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('AC'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Alarm'), + ), + ] + ) + ) + ) + ); + + expect(tester.renderObject(find.text('AC')).text.style.color, equals(fixedColor)); + }); + + testWidgets('setting selectedFontSize to zero hides all labels', (WidgetTester tester) async { + const double customElevation = 3.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + elevation: customElevation, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('AC'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Alarm'), + ), + ] + ) + ) + ) + ); + + expect(_getMaterial(tester).elevation, equals(customElevation)); + }); + testWidgets('BottomNavigationBar adds bottom padding to height', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -91,7 +412,7 @@ void main() { ) ); - const double labelBottomMargin = 8.0; // _kBottomMargin in implementation. + const double labelBottomMargin = 7.0; // 7 == defaulted selectedFontSize / 2.0. const double additionalPadding = 40.0 - labelBottomMargin; const double expectedHeight = kBottomNavigationBarHeight + additionalPadding; expect(tester.getSize(find.byType(BottomNavigationBar)).height, expectedHeight); @@ -393,7 +714,7 @@ void main() { ); final RenderBox box = tester.renderObject(find.byType(BottomNavigationBar)); - expect(box.size.height, equals(68.0)); + expect(box.size.height, equals(66.0)); }); testWidgets('BottomNavigationBar limits width of tiles with long titles', (WidgetTester tester) async { @@ -827,7 +1148,7 @@ void main() { await tester.pump(const Duration(milliseconds: 30)); await expectLater( find.byType(BottomNavigationBar), - matchesGoldenFile('bottom_navigation_bar.shifting_transition.$pump.png'), + matchesGoldenFile('bottom_navigation_bar.shifting_transition.2.$pump.png'), skip: !Platform.isLinux, ); } @@ -851,6 +1172,191 @@ void main() { ]))); }, throwsA(isInstanceOf())); }); + + testWidgets('BottomNavigationBar [showSelectedLabels]=false and [showUnselectedLabels]=false ' + 'for shifting navbar, expect that there is no rendered text', (WidgetTester tester) async { + final Widget widget = MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + type: BottomNavigationBarType.shifting, + items: const [ + BottomNavigationBarItem( + title: Text('Red'), + backgroundColor: Colors.red, + icon: Icon(Icons.dashboard), + ), + BottomNavigationBarItem( + title: Text('Green'), + backgroundColor: Colors.green, + icon: Icon(Icons.menu), + ), + ], + ), + ); + }, + ), + ); + await tester.pumpWidget(widget); + expect(find.text('Red'), findsOneWidget); + expect(find.text('Green'), findsOneWidget); + expect(tester.widget(find.byType(Opacity).first).opacity, 0.0); + expect(tester.widget(find.byType(Opacity).last).opacity, 0.0); + }); + + testWidgets('BottomNavigationBar [showSelectedLabels]=false and [showUnselectedLabels]=false ' + 'for fixed navbar, expect that there is no rendered text', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + type: BottomNavigationBarType.fixed, + items: const [ + BottomNavigationBarItem( + title: Text('Red'), + backgroundColor: Colors.red, + icon: Icon(Icons.dashboard), + ), + BottomNavigationBarItem( + title: Text('Green'), + backgroundColor: Colors.green, + icon: Icon(Icons.menu), + ), + ], + ), + ); + }, + ), + ), + ); + expect(find.text('Red'), findsOneWidget); + expect(find.text('Green'), findsOneWidget); + expect(tester.widget(find.byType(Opacity).first).opacity, 0.0); + expect(tester.widget(find.byType(Opacity).last).opacity, 0.0); + }); + + testWidgets('BottomNavigationBar.fixed [showSelectedLabels]=false and [showUnselectedLabels]=false semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerplate( + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('Red'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Green'), + ), + ], + ), + ), + ); + + final TestSemantics expected = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [ + SemanticsFlag.isSelected, + SemanticsFlag.isHeader, + ], + actions: [SemanticsAction.tap], + label: 'Red\nTab 1 of 2', + textDirection: TextDirection.ltr, + ), + TestSemantics( + flags: [ + SemanticsFlag.isHeader, + ], + actions: [SemanticsAction.tap], + label: 'Green\nTab 2 of 2', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ); + expect(semantics, hasSemantics(expected, ignoreId: true, ignoreTransform: true, ignoreRect: true)); + + semantics.dispose(); + }); + + testWidgets('BottomNavigationBar.shifting [showSelectedLabels]=false and [showUnselectedLabels]=false semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerplate( + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + type: BottomNavigationBarType.shifting, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + title: Text('Red'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.access_alarm), + title: Text('Green'), + ), + ], + ), + ), + ); + + final TestSemantics expected = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [ + SemanticsFlag.isSelected, + SemanticsFlag.isHeader, + ], + actions: [SemanticsAction.tap], + label: 'Red\nTab 1 of 2', + textDirection: TextDirection.ltr, + ), + TestSemantics( + flags: [ + SemanticsFlag.isHeader, + ], + actions: [SemanticsAction.tap], + label: 'Green\nTab 2 of 2', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ); + expect(semantics, hasSemantics(expected, ignoreId: true, ignoreTransform: true, ignoreRect: true)); + + semantics.dispose(); + }); + } Widget boilerplate({ Widget bottomNavigationBar, @required TextDirection textDirection }) { @@ -874,3 +1380,19 @@ Widget boilerplate({ Widget bottomNavigationBar, @required TextDirection textDir ), ); } + +double _getOpacity(WidgetTester tester, String textValue) { + final FadeTransition opacityWidget = tester.widget( + find.ancestor( + of: find.text(textValue), + matching: find.byType(FadeTransition), + ).first + ); + return opacityWidget.opacity.value; +} + +Material _getMaterial(WidgetTester tester) { + return tester.firstWidget( + find.descendant(of: find.byType(BottomNavigationBar), matching: find.byType(Material)), + ); +}