Unverified Commit 26c30fe2 authored by Hans Muller's avatar Hans Muller Committed by GitHub

Added BottomNavigationBar landscapeLayout parameter (#87211)

parent c5866c5e
......@@ -34,6 +34,31 @@ enum BottomNavigationBarType {
shifting,
}
/// Refines the layout of a [BottomNavigationBar] when the enclosing
/// [MediaQueryData.orientation] is [Orientation.landscape].
enum BottomNavigationBarLandscapeLayout {
/// If the enclosing [MediaQueryData.orientation] is
/// [Orientation.landscape] then the navigation bar's items are
/// evenly spaced and spread out across the available width. Each
/// item's label and icon are arranged in a column.
spread,
/// If the enclosing [MediaQueryData.orientation] is
/// [Orientation.landscape] then the navigation bar's items are
/// evenly spaced in a row but only consume as much width as they
/// would in portrait orientation. The row of items is centered within
/// the available width. Each item's label and icon are arranged
/// in a column.
centered,
/// If the enclosing [MediaQueryData.orientation] is
/// [Orientation.landscape] then the navigation bar's items are
/// evenly spaced and each item's icon and label are lined up in a
/// row instead of a column.
linear,
}
/// 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.
///
......@@ -277,6 +302,7 @@ class BottomNavigationBar extends StatefulWidget {
this.showUnselectedLabels,
this.mouseCursor,
this.enableFeedback,
this.landscapeLayout,
}) : assert(items != null),
assert(items.length >= 2),
assert(
......@@ -422,6 +448,40 @@ class BottomNavigationBar extends StatefulWidget {
/// * [Feedback] for providing platform-specific feedback to certain actions.
final bool? enableFeedback;
/// The arrangement of the bar's [items] when the enclosing
/// [MediaQueryData.orientation] is [Orientation.landscape].
///
/// The following alternatives are supported:
///
/// * [BottomNavigationBarLandscapeLayout.spread] - the items are
/// evenly spaced and spread out across the available width. Each
/// item's label and icon are arranged in a column.
/// * [BottomNavigationBarLandscapeLayout.centered] - the items are
/// evenly spaced in a row but only consume as much width as they
/// would in portrait orientation. The row of items is centered within
/// the available width. Each item's label and icon are arranged
/// in a column.
/// * [BottomNavigationBarLandscapeLayout.linear] - the items are
/// evenly spaced and each item's icon and label are lined up in a
/// row instead of a column.
///
/// If this property is null, then the value of the enclosing
/// [BottomNavigationBarThemeData.landscapeLayout is used. If that
/// property is also null, then
/// [BottomNavigationBarLandscapeLayout.spread] is used.
///
/// This property is null by default.
///
/// See also:
///
/// * [ThemeData.bottomNavigationBarTheme] - which can be used to specify
/// bottom navigation bar defaults for an entire application.
/// * [BottomNavigationBarTheme] - which can be used to specify
/// bottom navigation bar defaults for a widget subtree.
/// * [MediaQuery.of] - which can be used to determing the current
/// orientation.
final BottomNavigationBarLandscapeLayout? landscapeLayout;
@override
State<BottomNavigationBar> createState() => _BottomNavigationBarState();
}
......@@ -447,13 +507,14 @@ class _BottomNavigationTile extends StatelessWidget {
this.indexLabel,
required this.mouseCursor,
required this.enableFeedback,
}) : assert(type != null),
assert(item != null),
assert(animation != null),
assert(selected != null),
assert(selectedLabelStyle != null),
assert(unselectedLabelStyle != null),
assert(mouseCursor != null);
required this.layout,
}) : assert(type != null),
assert(item != null),
assert(animation != null),
assert(selected != null),
assert(selectedLabelStyle != null),
assert(unselectedLabelStyle != null),
assert(mouseCursor != null);
final BottomNavigationBarType type;
final BottomNavigationBarItem item;
......@@ -472,6 +533,7 @@ class _BottomNavigationTile extends StatelessWidget {
final bool showUnselectedLabels;
final MouseCursor mouseCursor;
final bool enableFeedback;
final BottomNavigationBarLandscapeLayout layout;
@override
Widget build(BuildContext context) {
......@@ -559,30 +621,26 @@ class _BottomNavigationTile extends StatelessWidget {
enableFeedback: enableFeedback,
child: Padding(
padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_TileIcon(
colorTween: colorTween!,
animation: animation,
iconSize: iconSize,
selected: selected,
item: item,
selectedIconTheme: selectedIconTheme,
unselectedIconTheme: unselectedIconTheme,
),
_Label(
colorTween: colorTween!,
animation: animation,
item: item,
selectedLabelStyle: selectedLabelStyle,
unselectedLabelStyle: unselectedLabelStyle,
showSelectedLabels: showSelectedLabels,
showUnselectedLabels: showUnselectedLabels,
),
],
child: _Tile(
layout: layout,
icon: _TileIcon(
colorTween: colorTween!,
animation: animation,
iconSize: iconSize,
selected: selected,
item: item,
selectedIconTheme: selectedIconTheme,
unselectedIconTheme: unselectedIconTheme,
),
label: _Label(
colorTween: colorTween!,
animation: animation,
item: item,
selectedLabelStyle: selectedLabelStyle,
unselectedLabelStyle: unselectedLabelStyle,
showSelectedLabels: showSelectedLabels,
showUnselectedLabels: showUnselectedLabels,
),
),
),
);
......@@ -618,6 +676,44 @@ class _BottomNavigationTile extends StatelessWidget {
}
// If the orientaion is landscape and layout is
// BottomNavigationBarLandscapeLayout.linear then return a
// icon-space-label row, where space is 8 pixels. Otherwise return a
// icon-label column.
class _Tile extends StatelessWidget {
const _Tile({
Key? key,
required this.layout,
required this.icon,
required this.label
}) : super(key: key);
final BottomNavigationBarLandscapeLayout layout;
final Widget icon;
final Widget label;
@override
Widget build(BuildContext context) {
final MediaQueryData data = MediaQuery.of(context);
if (data.orientation == Orientation.landscape && layout == BottomNavigationBarLandscapeLayout.linear) {
return Align(
heightFactor: 1,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[icon, const SizedBox(width: 8), label],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: <Widget>[icon, label],
);
}
}
class _TileIcon extends StatelessWidget {
const _TileIcon({
Key? key,
......@@ -917,7 +1013,7 @@ class _BottomNavigationBarState extends State<BottomNavigationBar> with TickerPr
return textStyle.fontSize == null ? textStyle.copyWith(fontSize: fontSize) : textStyle;
}
List<Widget> _createTiles() {
List<Widget> _createTiles(BottomNavigationBarLandscapeLayout layout) {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
assert(localizations != null);
......@@ -993,21 +1089,12 @@ class _BottomNavigationBarState extends State<BottomNavigationBar> with TickerPr
showUnselectedLabels: widget.showUnselectedLabels ?? bottomTheme.showUnselectedLabels ?? _defaultShowUnselected,
indexLabel: localizations.tabLabel(tabIndex: i + 1, tabCount: widget.items.length),
mouseCursor: effectiveMouseCursor,
layout: layout,
));
}
return tiles;
}
Widget _createContainer(List<Widget> tiles) {
return DefaultTextStyle.merge(
overflow: TextOverflow.ellipsis,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: tiles,
),
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasDirectionality(context));
......@@ -1016,7 +1103,11 @@ class _BottomNavigationBarState extends State<BottomNavigationBar> with TickerPr
assert(Overlay.of(context, debugRequiredFor: widget) != null);
final BottomNavigationBarThemeData bottomTheme = BottomNavigationBarTheme.of(context);
final BottomNavigationBarLandscapeLayout layout = widget.landscapeLayout
?? bottomTheme.landscapeLayout
?? BottomNavigationBarLandscapeLayout.spread;
final double additionalBottomPadding = MediaQuery.of(context).padding.bottom;
Color? backgroundColor;
switch (_effectiveType) {
case BottomNavigationBarType.fixed:
......@@ -1026,9 +1117,11 @@ class _BottomNavigationBarState extends State<BottomNavigationBar> with TickerPr
backgroundColor = _backgroundColor;
break;
}
return Semantics(
explicitChildNodes: true,
child: Material(
child: _Bar(
layout: layout,
elevation: widget.elevation ?? bottomTheme.elevation ?? 8.0,
color: backgroundColor,
child: ConstrainedBox(
......@@ -1045,7 +1138,13 @@ class _BottomNavigationBarState extends State<BottomNavigationBar> with TickerPr
child: MediaQuery.removePadding(
context: context,
removeBottom: true,
child: _createContainer(_createTiles()),
child: DefaultTextStyle.merge(
overflow: TextOverflow.ellipsis,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: _createTiles(layout),
),
),
),
),
),
......@@ -1056,6 +1155,44 @@ class _BottomNavigationBarState extends State<BottomNavigationBar> with TickerPr
}
}
// Optionally center a Material child for landscape layouts when layout is
// BottomNavigationBarLandscapeLayout.centered
class _Bar extends StatelessWidget {
const _Bar({
Key? key,
required this.child,
required this.layout,
required this.elevation,
required this.color,
}) : super(key: key);
final Widget child;
final BottomNavigationBarLandscapeLayout layout;
final double elevation;
final Color? color;
@override
Widget build(BuildContext context) {
final MediaQueryData data = MediaQuery.of(context);
Widget alignedChild = child;
if (data.orientation == Orientation.landscape && layout == BottomNavigationBarLandscapeLayout.centered) {
alignedChild = Align(
alignment: Alignment.bottomCenter,
heightFactor: 1,
child: SizedBox(
width: data.size.height,
child: child,
),
);
}
return Material(
elevation: elevation,
color: color,
child: alignedChild,
);
}
}
// Describes an animating color splash circle.
class _Circle {
_Circle({
......
......@@ -44,6 +44,7 @@ class BottomNavigationBarThemeData with Diagnosticable {
this.showUnselectedLabels,
this.type,
this.enableFeedback,
this.landscapeLayout,
});
/// The color of the [BottomNavigationBar] itself.
......@@ -120,6 +121,9 @@ class BottomNavigationBarThemeData with Diagnosticable {
/// If [BottomNavigationBar.enableFeedback] is provided, [enableFeedback] is ignored.
final bool? enableFeedback;
/// If non-null, overrides the [BottomNavigationBar.landscapeLayout] property.
final BottomNavigationBarLandscapeLayout? landscapeLayout;
/// Creates a copy of this object but with the given fields replaced with the
/// new values.
BottomNavigationBarThemeData copyWith({
......@@ -135,6 +139,7 @@ class BottomNavigationBarThemeData with Diagnosticable {
bool? showUnselectedLabels,
BottomNavigationBarType? type,
bool? enableFeedback,
BottomNavigationBarLandscapeLayout? landscapeLayout
}) {
return BottomNavigationBarThemeData(
backgroundColor: backgroundColor ?? this.backgroundColor,
......@@ -149,6 +154,7 @@ class BottomNavigationBarThemeData with Diagnosticable {
showUnselectedLabels: showUnselectedLabels ?? this.showUnselectedLabels,
type: type ?? this.type,
enableFeedback: enableFeedback ?? this.enableFeedback,
landscapeLayout: landscapeLayout ?? this.landscapeLayout,
);
}
......@@ -172,6 +178,7 @@ class BottomNavigationBarThemeData with Diagnosticable {
showUnselectedLabels: t < 0.5 ? a?.showUnselectedLabels : b?.showUnselectedLabels,
type: t < 0.5 ? a?.type : b?.type,
enableFeedback: t < 0.5 ? a?.enableFeedback : b?.enableFeedback,
landscapeLayout: t < 0.5 ? a?.landscapeLayout : b?.landscapeLayout,
);
}
......@@ -190,6 +197,7 @@ class BottomNavigationBarThemeData with Diagnosticable {
showUnselectedLabels,
type,
enableFeedback,
landscapeLayout,
);
}
......@@ -211,7 +219,8 @@ class BottomNavigationBarThemeData with Diagnosticable {
&& other.showSelectedLabels == showSelectedLabels
&& other.showUnselectedLabels == showUnselectedLabels
&& other.type == type
&& other.enableFeedback == enableFeedback;
&& other.enableFeedback == enableFeedback
&& other.landscapeLayout == landscapeLayout;
}
@override
......@@ -229,6 +238,7 @@ class BottomNavigationBarThemeData with Diagnosticable {
properties.add(DiagnosticsProperty<bool>('showUnselectedLabels', showUnselectedLabels, defaultValue: null));
properties.add(DiagnosticsProperty<BottomNavigationBarType>('type', type, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('enableFeedback', enableFeedback, defaultValue: null));
properties.add(DiagnosticsProperty<BottomNavigationBarLandscapeLayout>('landscapeLayout', landscapeLayout, defaultValue: null));
}
}
......
......@@ -2015,6 +2015,143 @@ void main() {
semantics.dispose();
});
testWidgets('BottomNavigationBar default layout', (WidgetTester tester) async {
final Key icon0 = UniqueKey();
final Key title0 = UniqueKey();
final Key icon1 = UniqueKey();
final Key title1 = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
return Scaffold(
bottomNavigationBar: BottomNavigationBar(
currentIndex: 0,
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: SizedBox(key: icon0, width: 200, height: 10),
title: SizedBox(key: title0, width: 200, height: 10),
),
BottomNavigationBarItem(
icon: SizedBox(key: icon1, width: 200, height: 10),
title: SizedBox(key: title1, width: 200, height: 10),
),
],
),
);
},
),
),
);
expect(tester.getSize(find.byType(BottomNavigationBar)), const Size(800, kBottomNavigationBarHeight));
expect(tester.getRect(find.byType(BottomNavigationBar)), const Rect.fromLTRB(0, 600 - kBottomNavigationBarHeight, 800, 600));
// The height of the navigation bar is kBottomNavigationBarHeight = 56
// The top of the navigation bar is 600 - 56 = 544
// The top and bottom of the selected item is defined by its centered icon/label column:
// top = 544 - (56 - (10 + 10)) / 2 = 562
// bottom = top + 10 + 10 = 582
expect(tester.getRect(find.byKey(icon0)).top, 562);
expect(tester.getRect(find.byKey(title0)).bottom, 582);
// The items are horizontal padded according to
// MainAxisAlignment.spaceBetween Left/right padding is 800 - (200
// * 4) / 4 = 100. The layout of the unselected item's title is
// slightly different; not checking that here.
expect(tester.getRect(find.byKey(title0)), const Rect.fromLTRB(100, 572, 300, 582));
expect(tester.getRect(find.byKey(icon0)), const Rect.fromLTRB(100, 562, 300, 572));
expect(tester.getRect(find.byKey(icon1)), const Rect.fromLTRB(500, 562, 700, 572));
});
testWidgets('BottomNavigationBar centered landscape layout', (WidgetTester tester) async {
final Key icon0 = UniqueKey();
final Key title0 = UniqueKey();
final Key icon1 = UniqueKey();
final Key title1 = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
return Scaffold(
bottomNavigationBar: BottomNavigationBar(
currentIndex: 0,
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: SizedBox(key: icon0, width: 200, height: 10),
title: SizedBox(key: title0, width: 200, height: 10),
),
BottomNavigationBarItem(
icon: SizedBox(key: icon1, width: 200, height: 10),
title: SizedBox(key: title1, width: 200, height: 10),
),
],
),
);
},
),
),
);
expect(tester.getSize(find.byType(BottomNavigationBar)), const Size(800, kBottomNavigationBarHeight));
expect(tester.getRect(find.byType(BottomNavigationBar)), const Rect.fromLTRB(0, 600 - kBottomNavigationBarHeight, 800, 600));
// The items are laid out as in the default case, within width=600
// (the "portrait" width) and the result is centered with the
// landscape width=800. So item 0's left edges are (800 - 600) / 2 +
// (600 - 400) / 4 = 150. Item 1's right edge is 800 - 150 =
// 650. The layout of the unselected item's title is slightly
// different; not checking that here.
expect(tester.getRect(find.byKey(title0)), const Rect.fromLTRB(150.0, 572.0, 350.0, 582.0));
expect(tester.getRect(find.byKey(icon0)), const Rect.fromLTRB(150, 562, 350, 572));
expect(tester.getRect(find.byKey(icon1)), const Rect.fromLTRB(450, 562, 650, 572));
});
testWidgets('BottomNavigationBar linear landscape layout', (WidgetTester tester) async {
final Key icon0 = UniqueKey();
final Key title0 = UniqueKey();
final Key icon1 = UniqueKey();
final Key title1 = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (BuildContext context) {
return Scaffold(
bottomNavigationBar: BottomNavigationBar(
currentIndex: 0,
landscapeLayout: BottomNavigationBarLandscapeLayout.linear,
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: SizedBox(key: icon0, width: 100, height: 20),
title: SizedBox(key: title0, width: 100, height: 20),
),
BottomNavigationBarItem(
icon: SizedBox(key: icon1, width: 100, height: 20),
title: SizedBox(key: title1, width: 100, height: 20),
),
],
),
);
},
),
),
);
expect(tester.getSize(find.byType(BottomNavigationBar)), const Size(800, kBottomNavigationBarHeight));
expect(tester.getRect(find.byType(BottomNavigationBar)), const Rect.fromLTRB(0, 600 - kBottomNavigationBarHeight, 800, 600));
// The items are laid out as in the default case except each
// item's icon/title is arranged in a row, with 8 pixels in
// between the icon and title. The layout of the unselected
// item's title is slightly different; not checking that here.
expect(tester.getRect(find.byKey(title0)), const Rect.fromLTRB(204, 562, 304, 582));
expect(tester.getRect(find.byKey(icon0)), const Rect.fromLTRB(96, 562, 196, 582));
expect(tester.getRect(find.byKey(icon1)), const Rect.fromLTRB(496, 562, 596, 582));
});
}
Widget boilerplate({ Widget? bottomNavigationBar, required TextDirection textDirection }) {
......
......@@ -27,6 +27,7 @@ void main() {
expect(themeData.showSelectedLabels, null);
expect(themeData.showUnselectedLabels, null);
expect(themeData.type, null);
expect(themeData.landscapeLayout, null);
const BottomNavigationBarTheme theme = BottomNavigationBarTheme(data: BottomNavigationBarThemeData(), child: SizedBox());
expect(theme.data.backgroundColor, null);
......@@ -40,6 +41,7 @@ void main() {
expect(theme.data.showSelectedLabels, null);
expect(theme.data.showUnselectedLabels, null);
expect(theme.data.type, null);
expect(themeData.landscapeLayout, null);
});
testWidgets('Default BottomNavigationBarThemeData debugFillProperties', (WidgetTester tester) async {
......@@ -175,6 +177,7 @@ void main() {
const TextStyle themeSelectedTextStyle = TextStyle(fontSize: 22);
const TextStyle themeUnselectedTextStyle = TextStyle(fontSize: 21);
const double themeElevation = 9.0;
const BottomNavigationBarLandscapeLayout themeLandscapeLayout = BottomNavigationBarLandscapeLayout.centered;
const Color backgroundColor = Color(0xFF000004);
const Color selectedItemColor = Color(0xFF000005);
......@@ -184,6 +187,7 @@ void main() {
const TextStyle selectedTextStyle = TextStyle(fontSize: 25);
const TextStyle unselectedTextStyle = TextStyle(fontSize: 26);
const double elevation = 7.0;
const BottomNavigationBarLandscapeLayout landscapeLayout = BottomNavigationBarLandscapeLayout.spread;
await tester.pumpWidget(
MaterialApp(
......@@ -200,6 +204,7 @@ void main() {
type: BottomNavigationBarType.shifting,
selectedLabelStyle: themeSelectedTextStyle,
unselectedLabelStyle: themeUnselectedTextStyle,
landscapeLayout: themeLandscapeLayout,
),
),
home: Scaffold(
......@@ -215,6 +220,7 @@ void main() {
type: BottomNavigationBarType.fixed,
selectedLabelStyle: selectedTextStyle,
unselectedLabelStyle: unselectedTextStyle,
landscapeLayout: landscapeLayout,
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.ac_unit),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment