Commit 3a6530d5 authored by Dragoș Tiselice's avatar Dragoș Tiselice Committed by GitHub

Added bottom navigation. (#5877)

parent d3f540d4
......@@ -15,6 +15,7 @@ export 'src/material/about.dart';
export 'src/material/app.dart';
export 'src/material/app_bar.dart';
export 'src/material/arc.dart';
export 'src/material/bottom_navigation_bar.dart';
export 'src/material/bottom_sheet.dart';
export 'src/material/button.dart';
export 'src/material/button_bar.dart';
......
// Copyright 2016 The Chromium 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:math' as math;
import 'dart:collection' show Queue;
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
import 'package:vector_math/vector_math_64.dart' show Vector3;
import 'colors.dart';
import 'constants.dart';
import 'icon.dart';
import 'icon_theme.dart';
import 'icon_theme_data.dart';
import 'ink_well.dart';
import 'material.dart';
import 'theme.dart';
const double _kActiveMaxWidth = 168.0;
const double _kInactiveMaxWidth = 96.0;
/// Defines the layout and behavior of a [BottomNavigationBar].
///
/// See also:
///
/// * [BottomNavigationBar]
/// * [DestinationLabel]
/// * <https://material.google.com/components/bottom-navigation.html#bottom-navigation-specs>
enum BottomNavigationBarType {
/// The [BottomNavigationBar]'s [DestinationLabel]s have fixed width.
fixed,
/// The location and size of the [BottomNavigationBar] [DestinationLabel]s
/// animate larger when they are tapped.
shifting,
}
/// An interactive destination label within [BottomNavigationBar] with an icon
/// and title.
///
/// See also:
///
/// * [BottomNavigationBar]
/// * <https://material.google.com/components/bottom-navigation.html>
class DestinationLabel {
/// Creates a label that is used with [BottomNavigationBar.labels].
///
/// The arguments [icon] and [title] should not be null.
DestinationLabel({
@required this.icon,
@required this.title,
this.backgroundColor
}) {
assert(this.icon != null);
assert(this.title != null);
}
/// The icon of the label.
final Icon icon;
/// The title of the label.
final Widget title;
/// The color of the background radial animation.
///
/// If the navigation bar's type is [BottomNavigationBarType.shifting], then
/// the entire bar is flooded with the [backgroundColor] when this label is
/// tapped.
final Color backgroundColor;
}
/// A material widget displayed at the bottom of an app for selecting among a
/// small number of views.
///
/// The bottom navigation bar consists of multiple destinations in the form of
/// labels laid out on top of a piece of material. It provies quick navigation
/// between top-level views of an app and is typically used on mobile. For
/// larger screens, side navigation may be a better fit.
///
/// A bottom navigation bar is usually used in conjunction with [Scaffold] where
/// it is provided as the [Scaffold.bottomNavigationBar] argument.
///
/// See also:
///
/// * [DestinationLabel]
/// * [Scaffold]
/// * <https://material.google.com/components/bottom-navigation.html>
class BottomNavigationBar extends StatefulWidget {
/// Creates a bottom navigation bar, typically used in a [Scaffold] where it
/// is provided as the [Scaffold.bottomNavigationBar] argument.
///
/// The arguments [labels] and [type] should not be null.
///
/// The number of labels passed should be equal or greater than 2.
///
/// Passing a null [fixedColor] will cause a fallback to the theme's primary
/// color.
BottomNavigationBar({
Key key,
@required this.labels,
this.onTap,
this.currentIndex: 0,
this.type: BottomNavigationBarType.fixed,
this.fixedColor
}) : super(key: key) {
assert(this.labels != null);
assert(this.labels.length >= 2);
assert(0 <= currentIndex && currentIndex < this.labels.length);
assert(this.type != null);
assert(
this.type == BottomNavigationBarType.fixed || this.fixedColor == null
);
}
/// The interactive labels laid out within the bottom navigation bar.
final List<DestinationLabel> labels;
/// The callback that is called when a label 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.
final ValueChanged<int> onTap;
/// The index into [labels] of the current active label.
final int currentIndex;
/// Defines the layout and behavior of a [BottomNavigationBar].
final BottomNavigationBarType type;
/// The color of the selected label when bottom navigation bar is
/// [BottomNavigationBarType.fixed].
final Color fixedColor;
@override
BottomNavigationBarState createState() => new BottomNavigationBarState();
}
class BottomNavigationBarState extends State<BottomNavigationBar> {
List<AnimationController> _controllers;
List<CurvedAnimation> animations;
double _weight;
final Queue<_Circle> _circles = new Queue<_Circle>();
Color _backgroundColor; // Last growing circle's color.
static final Tween<double> _flexTween = new Tween<double>(
begin: 1.0,
end: 1.5
);
@override
void initState() {
super.initState();
_controllers = new List<AnimationController>.generate(config.labels.length, (int index) {
return new AnimationController(
duration: kThemeAnimationDuration
)..addListener(_rebuild);
});
animations = new List<CurvedAnimation>.generate(config.labels.length, (int index) {
return new CurvedAnimation(
parent: _controllers[index],
curve: Curves.fastOutSlowIn,
reverseCurve: Curves.fastOutSlowIn.flipped
);
});
_controllers[config.currentIndex].value = 1.0;
_backgroundColor = config.labels[config.currentIndex].backgroundColor;
}
@override
void dispose() {
for (AnimationController controller in _controllers)
controller.dispose();
for (_Circle circle in _circles)
circle.dispose();
super.dispose();
}
void _rebuild() {
setState(() {
// Rebuilding when any of the controllers tick, i.e. when the labels are
// animated.
});
}
double get _maxWidth {
assert(config.type != null);
switch (config.type) {
case BottomNavigationBarType.fixed:
return config.labels.length * _kActiveMaxWidth;
case BottomNavigationBarType.shifting:
return _kActiveMaxWidth + (config.labels.length - 1) * _kInactiveMaxWidth;
}
return null;
}
bool _isAnimating(Animation<double> animation) {
return animation.status == AnimationStatus.forward ||
animation.status == AnimationStatus.reverse;
}
// Because of non-linear nature of the animations, the animations that are
// currently animating might not add up to the flex weight we are expecting.
// (1.5 + N - 1, since the max flex that the animating ones can have is 1.5)
// This causes instability in the animation when multiple labels are tapped.
// To solves this, we always store a weight that normalizes animating
// animations such that their resulting flex values will add up to the desired
// value.
void _computeWeight() {
final Iterable<Animation<double>> animating = animations.where(
(Animation<double> animation) => _isAnimating(animation)
);
if (animating.isNotEmpty) {
final double sum = animating.fold(0.0, (double sum, Animation<double> animation) {
return sum + _flexTween.evaluate(animation);
});
_weight = (animating.length + 0.5) / sum;
} else {
_weight = 1.0;
}
}
double _flex(Animation<double> animation) {
if (_isAnimating(animation)) {
assert(_weight != null);
return _flexTween.evaluate(animation) * _weight;
} else {
return _flexTween.evaluate(animation);
}
}
double _xOffset(int index) {
double weightSum(Iterable<Animation<double>> animations) {
return animations.map(
// We're adding flex values instead of animation values to have correct
// ratios.
(Animation<double> animation) => _flex(animation)
).fold(0.0, (double sum, double value) => sum + value);
}
final double allWeights = weightSum(animations);
// This weight corresponds to the left edge of the indexed label.
final double leftWeights = weightSum(animations.sublist(0, index));
// Add half of its flex value in order to get the center.
return (leftWeights + _flex(animations[index]) / 2.0) / allWeights;
}
FractionalOffset cirleOffset(int index) {
final double iconSize = config.labels[index].icon.size ?? 24.0;
final Tween<double> yOffsetTween = new Tween<double>(
begin: (18.0 + iconSize / 2.0) / kBottomNavigationBarHeight, // 18dp + icon center
end: (6.0 + iconSize / 2.0) / kBottomNavigationBarHeight // 6dp + icon center
);
return new FractionalOffset(
_xOffset(index),
yOffsetTween.evaluate(animations[index])
);
}
void _pushCircle(int index) {
if (config.labels[index].backgroundColor != null)
_circles.add(
new _Circle(
state: this,
index: index,
color: config.labels[index].backgroundColor
)..controller.addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.completed) {
setState(() {
_Circle circle = _circles.removeFirst();
_backgroundColor = circle.color;
circle.dispose();
});
}
})
);
}
@override
void didUpdateConfig(BottomNavigationBar oldConfig) {
if (config.currentIndex != oldConfig.currentIndex) {
if (config.type == BottomNavigationBarType.shifting)
_pushCircle(config.currentIndex);
_controllers[oldConfig.currentIndex].reverse();
_controllers[config.currentIndex].forward();
}
}
@override
Widget build(BuildContext context) {
Widget bottomNavigation;
switch (config.type) {
case BottomNavigationBarType.fixed:
final List<Widget> children = <Widget>[];
final ThemeData themeData = Theme.of(context);
final ColorTween colorTween = new ColorTween(
begin: Colors.black54,
end: config.fixedColor ?? themeData.primaryColor
);
for (int i = 0; i < config.labels.length; i += 1) {
children.add(
new Flexible(
child: new InkResponse(
onTap: () {
if (config.onTap != null)
config.onTap(i);
},
child: new Stack(
alignment: FractionalOffset.center,
children: <Widget>[
new Align(
alignment: FractionalOffset.topCenter,
child: new Container(
margin: new EdgeInsets.only(
top: new Tween<double>(
begin: 8.0,
end: 6.0
).evaluate(animations[i])
),
child: new IconTheme(
data: new IconThemeData(
color: colorTween.evaluate(animations[i])
),
child: config.labels[i].icon
)
)
),
new Align(
alignment: FractionalOffset.bottomCenter,
child: new Container(
margin: const EdgeInsets.only(bottom: 10.0),
child: new DefaultTextStyle(
style: new TextStyle(
fontSize: 14.0,
color: colorTween.evaluate(animations[i])
),
child: new Transform(
transform: new Matrix4.diagonal3(new Vector3.all(
new Tween<double>(
begin: 0.85,
end: 1.0,
).evaluate(animations[i])
)),
alignment: FractionalOffset.bottomCenter,
child: config.labels[i].title
)
)
)
)
]
)
)
)
);
}
bottomNavigation = new SizedBox(
width: _maxWidth,
child: new Row(
children: children
)
);
break;
case BottomNavigationBarType.shifting:
final List<Widget> children = <Widget>[];
_computeWeight();
for (int i = 0; i < config.labels.length; i += 1) {
children.add(
new Flexible(
// Since Flexible only supports integers, we're using large
// numbers in order to simulate floating point flex values.
flex: (_flex(animations[i]) * 1000.0).round(),
child: new InkResponse(
onTap: () {
if (config.onTap != null)
config.onTap(i);
},
child: new Stack(
alignment: FractionalOffset.center,
children: <Widget>[
new Align(
alignment: FractionalOffset.topCenter,
child: new Container(
margin: new EdgeInsets.only(
top: new Tween<double>(
begin: 18.0,
end: 6.0
).evaluate(animations[i])
),
child: new IconTheme(
data: new IconThemeData(
color: Colors.white
),
child: config.labels[i].icon
)
)
),
new Align(
alignment: FractionalOffset.bottomCenter,
child: new Container(
margin: const EdgeInsets.only(bottom: 10.0),
child: new FadeTransition(
opacity: animations[i],
child: new DefaultTextStyle(
style: new TextStyle(
fontSize: 14.0,
color: Colors.white
),
child: config.labels[i].title
)
)
)
)
]
)
)
)
);
}
bottomNavigation = new SizedBox(
width: _maxWidth,
child: new Row(
children: children
)
);
break;
}
return new Stack(
children: <Widget>[
new Positioned.fill(
child: new Material( // Casts shadow.
elevation: 8,
color: _backgroundColor
)
),
new SizedBox(
height: kBottomNavigationBarHeight,
child: new Center(
child: new Stack(
children: <Widget>[
new Positioned(
left: 0.0,
top: 0.0,
right: 0.0,
bottom: 0.0,
child: new CustomPaint(
painter: new _RadialPainter(
circles: _circles.toList()
)
)
),
new Material( // Splashes.
type: MaterialType.transparency,
child: new Center(
child: bottomNavigation
)
)
]
)
)
)
]
);
}
}
class _Circle {
_Circle({
this.state,
this.index,
this.color
}) {
assert(this.state != null);
assert(this.index != null);
assert(this.color != null);
controller = new AnimationController(
duration: kThemeAnimationDuration
);
animation = new CurvedAnimation(
parent: controller,
curve: Curves.fastOutSlowIn
);
controller.forward();
}
final BottomNavigationBarState state;
final int index;
final Color color;
AnimationController controller;
CurvedAnimation animation;
FractionalOffset get offset {
return state.cirleOffset(index);
}
void dispose() {
controller.dispose();
}
}
class _RadialPainter extends CustomPainter {
_RadialPainter({
this.circles
});
final List<_Circle> circles;
// Computes the maximum radius attainable such that at least one of the
// bounding rectangle's corners touches the egde of the circle. Drawing a
// circle beyond this radius is futile since there is no perceivable
// difference within the cropped rectangle.
double _maxRadius(FractionalOffset offset, Size size) {
final double dx = offset.dx;
final double dy = offset.dy;
final double x = (dx > 0.5 ? dx : 1.0 - dx) * size.width;
final double y = (dy > 0.5 ? dy : 1.0 - dy) * size.height;
return math.sqrt(x * x + y * y);
}
@override
bool shouldRepaint(_RadialPainter oldPainter) {
if (circles == oldPainter.circles)
return false;
if (circles.length != oldPainter.circles.length)
return true;
for (int i = 0; i < circles.length; i += 1)
if (circles[i] != oldPainter.circles[i])
return true;
return false;
}
@override
void paint(Canvas canvas, Size size) {
for (_Circle circle in circles) {
final Tween<double> radiusTween = new Tween<double>(
begin: 0.0,
end: _maxRadius(circle.offset, size)
);
final Paint paint = new Paint()..color = circle.color;
final Rect rect = new Rect.fromLTWH(0.0, 0.0, size.width, size.height);
canvas.clipRect(rect);
canvas.drawCircle(
circle.offset.withinRect(rect),
radiusTween.lerp(circle.animation.value),
paint
);
}
}
}
......@@ -5,6 +5,9 @@
/// The height of the toolbar component of the [AppBar].
const double kToolbarHeight = 56.0;
/// The height of the bottom navigation bar.
const double kBottomNavigationBarHeight = 60.0;
/// The height of a tab bar containing text.
const double kTextTabBarHeight = 48.0;
......
......@@ -53,6 +53,7 @@ enum _ScaffoldSlot {
appBar,
bottomSheet,
snackBar,
bottomNavigationBar,
floatingActionButton,
drawer,
statusBar,
......@@ -80,7 +81,8 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
final BoxConstraints fullWidthConstraints = looseConstraints.tighten(width: size.width);
double contentTop = padding.top;
double contentBottom = size.height - padding.bottom;
double bottom = size.height - padding.bottom;
double contentBottom = bottom;
if (hasChild(_ScaffoldSlot.appBar)) {
final double appBarHeight = layoutChild(_ScaffoldSlot.appBar, fullWidthConstraints).height;
......@@ -89,6 +91,12 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
positionChild(_ScaffoldSlot.appBar, Offset.zero);
}
if (hasChild(_ScaffoldSlot.bottomNavigationBar)) {
final double bottomNavigationBarHeight = layoutChild(_ScaffoldSlot.bottomNavigationBar, fullWidthConstraints).height;
contentBottom -= bottomNavigationBarHeight;
positionChild(_ScaffoldSlot.bottomNavigationBar, new Offset(0.0, contentBottom));
}
if (hasChild(_ScaffoldSlot.body)) {
final double bodyHeight = contentBottom - contentTop;
final BoxConstraints bodyConstraints = fullWidthConstraints.tighten(height: bodyHeight);
......@@ -97,7 +105,10 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
}
// The BottomSheet and the SnackBar are anchored to the bottom of the parent,
// they're as wide as the parent and are given their intrinsic height.
// they're as wide as the parent and are given their intrinsic height. The
// only difference is that SnackBar appears on the top side of the
// BottomNavigationBar while the BottomSheet is stacked on top of it.
//
// If all three elements are present then either the center of the FAB straddles
// the top edge of the BottomSheet or the bottom of the FAB is
// _kFloatingActionButtonMargin above the SnackBar, whichever puts the FAB
......@@ -110,7 +121,7 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
if (hasChild(_ScaffoldSlot.bottomSheet)) {
bottomSheetSize = layoutChild(_ScaffoldSlot.bottomSheet, fullWidthConstraints);
positionChild(_ScaffoldSlot.bottomSheet, new Offset((size.width - bottomSheetSize.width) / 2.0, contentBottom - bottomSheetSize.height));
positionChild(_ScaffoldSlot.bottomSheet, new Offset((size.width - bottomSheetSize.width) / 2.0, bottom - bottomSheetSize.height));
}
if (hasChild(_ScaffoldSlot.snackBar)) {
......@@ -265,6 +276,7 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
/// * [AppBar]
/// * [FloatingActionButton]
/// * [Drawer]
/// * [BottomNavigationBar]
/// * [SnackBar]
/// * [BottomSheet]
/// * [ScaffoldState]
......@@ -281,6 +293,7 @@ class Scaffold extends StatefulWidget {
this.body,
this.floatingActionButton,
this.drawer,
this.bottomNavigationBar,
this.scrollableKey,
this.appBarBehavior: AppBarBehavior.anchor,
this.resizeToAvoidBottomPadding: true
......@@ -306,6 +319,12 @@ class Scaffold extends StatefulWidget {
/// Typically a [Drawer].
final Widget drawer;
/// A bottom navigation bar to display at the bottom of the scaffold.
///
/// Snack bars slide from underneath the botton navigation while bottom sheets
/// are stacked on top.
final Widget bottomNavigationBar;
/// The key of the primary [Scrollable] widget in the [body].
///
/// Used to control scroll-linked effects, such as the collapse of the
......@@ -759,7 +778,18 @@ class ScaffoldState extends State<Scaffold> {
} else {
children.add(new LayoutId(child: _buildScrollableAppBar(context, padding), id: _ScaffoldSlot.appBar));
}
// Otherwise the AppBar will be part of a [app bar, body] Stack. See AppBarBehavior.scroll below.
// Otherwise the AppBar will be part of a [app bar, body] Stack. See
// AppBarBehavior.scroll below.
if (_snackBars.isNotEmpty)
_addIfNonNull(children, _snackBars.first._widget, _ScaffoldSlot.snackBar);
if (config.bottomNavigationBar != null) {
children.add(new LayoutId(
id: _ScaffoldSlot.bottomNavigationBar,
child: config.bottomNavigationBar
));
}
if (_currentBottomSheet != null || _dismissedBottomSheets.isNotEmpty) {
final List<Widget> bottomSheets = <Widget>[];
......@@ -774,9 +804,6 @@ class ScaffoldState extends State<Scaffold> {
_addIfNonNull(children, stack, _ScaffoldSlot.bottomSheet);
}
if (_snackBars.isNotEmpty)
_addIfNonNull(children, _snackBars.first._widget, _ScaffoldSlot.snackBar);
children.add(new LayoutId(
id: _ScaffoldSlot.floatingActionButton,
child: new _FloatingActionButtonTransition(
......
// Copyright 2016 The Chromium 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('BottomNavigationBar callback test', (WidgetTester tester) async {
int mutatedIndex;
await tester.pumpWidget(
new Scaffold(
bottomNavigationBar: new BottomNavigationBar(
labels: <DestinationLabel>[
new DestinationLabel(
icon: new Icon(Icons.ac_unit),
title: new Text('AC')
),
new DestinationLabel(
icon: new Icon(Icons.access_alarm),
title: new Text('Alarm')
)
],
onTap: (int index) {
mutatedIndex = index;
}
)
)
);
await tester.tap(find.text('Alarm'));
expect(mutatedIndex, 1);
});
testWidgets('BottomNavigationBar content test', (WidgetTester tester) async {
await tester.pumpWidget(
new Scaffold(
bottomNavigationBar: new BottomNavigationBar(
labels: <DestinationLabel>[
new DestinationLabel(
icon: new Icon(Icons.ac_unit),
title: new Text('AC')
),
new DestinationLabel(
icon: new Icon(Icons.access_alarm),
title: new Text('Alarm')
)
]
)
)
);
RenderBox box = tester.renderObject(find.byType(BottomNavigationBar));
expect(box.size.height, 60.0);
expect(find.text('AC'), findsOneWidget);
expect(find.text('Alarm'), findsOneWidget);
});
testWidgets('BottomNavigationBar action size test', (WidgetTester tester) async {
await tester.pumpWidget(
new Scaffold(
bottomNavigationBar: new BottomNavigationBar(
type: BottomNavigationBarType.shifting,
labels: <DestinationLabel>[
new DestinationLabel(
icon: new Icon(Icons.ac_unit),
title: new Text('AC')
),
new DestinationLabel(
icon: new Icon(Icons.access_alarm),
title: new Text('Alarm')
)
]
)
)
);
Iterable<RenderBox> actions = tester.renderObjectList(find.byType(InkResponse));
expect(actions.length, 2);
expect(actions.elementAt(0).size.width, 158.4);
expect(actions.elementAt(1).size.width, 105.6);
await tester.pumpWidget(
new Scaffold(
bottomNavigationBar: new BottomNavigationBar(
currentIndex: 1,
type: BottomNavigationBarType.shifting,
labels: <DestinationLabel>[
new DestinationLabel(
icon: new Icon(Icons.ac_unit),
title: new Text('AC')
),
new DestinationLabel(
icon: new Icon(Icons.access_alarm),
title: new Text('Alarm')
)
]
)
)
);
await tester.pump(const Duration(milliseconds: 200));
actions = tester.renderObjectList(find.byType(InkResponse));
expect(actions.length, 2);
expect(actions.elementAt(0).size.width, 105.6);
expect(actions.elementAt(1).size.width, 158.4);
});
testWidgets('BottomNavigationBar multiple taps test', (WidgetTester tester) async {
await tester.pumpWidget(
new Scaffold(
bottomNavigationBar: new BottomNavigationBar(
type: BottomNavigationBarType.shifting,
labels: <DestinationLabel>[
new DestinationLabel(
icon: new Icon(Icons.ac_unit),
title: new Text('AC')
),
new DestinationLabel(
icon: new Icon(Icons.access_alarm),
title: new Text('Alarm')
),
new DestinationLabel(
icon: new Icon(Icons.access_time),
title: new Text('Time')
),
new DestinationLabel(
icon: new Icon(Icons.add),
title: new Text('Add')
)
]
)
)
);
// We want to make sure that the last label does not get displaced,
// irrespective of how many taps happen on the first N - 1 labels and how
// they grow.
Iterable<RenderBox> actions = tester.renderObjectList(find.byType(InkResponse));
Point originalOrigin = actions.elementAt(3).localToGlobal(Point.origin);
await tester.tap(find.text('AC'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
actions = tester.renderObjectList(find.byType(InkResponse));
expect(actions.elementAt(3).localToGlobal(Point.origin), equals(originalOrigin));
await tester.tap(find.text('Alarm'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
actions = tester.renderObjectList(find.byType(InkResponse));
expect(actions.elementAt(3).localToGlobal(Point.origin), equals(originalOrigin));
await tester.tap(find.text('Time'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
actions = tester.renderObjectList(find.byType(InkResponse));
expect(actions.elementAt(3).localToGlobal(Point.origin), equals(originalOrigin));
});
}
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