Skip to content

Instantly share code, notes, and snippets.

@brookmg
Created February 5, 2019 14:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save brookmg/561173b1fe8eee420912d5c849755c41 to your computer and use it in GitHub Desktop.
Save brookmg/561173b1fe8eee420912d5c849755c41 to your computer and use it in GitHub Desktop.
Bottom Navigation Bar implementation for flutter that doesn't show labels under items.
// 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:collection' show Queue;
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
/// Defines the layout and behavior of a [BottomNavigationBar].
///
/// See also:
///
/// * [BottomNavigationBar]
/// * [BottomNavigationBarItem]
/// * <https://material.google.com/components/bottom-navigation.html#bottom-navigation-specs>
enum BottomNavigationBarTypeImpl {
/// The [BottomNavigationBar]'s [BottomNavigationBarItem]s have fixed width, always
/// do not shift when tapped. doesn't show labels
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. But don't use this for now because it doesn't work well
shifting,
}
/// A material widget 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
/// provides quick navigation between the top-level views of an app. For larger
/// screens, side navigation may be a better fit.
///
/// A bottom navigation bar is usually used in conjunction with a [Scaffold],
/// 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 [BottomNavigationBarTypeImpl.fixed]
/// when there are less than four items, [BottomNavigationBarTypeImpl.shifting]
/// otherwise.
///
/// * [BottomNavigationBarTypeImpl.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
/// color, [ThemeData.canvasColor] (essentially opaque white).
/// * [BottomNavigationBarTypeImpl.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
/// [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.
///
/// ## Sample Code
///
/// 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].
///
/// ```dart
/// class MyHomePage extends StatefulWidget {
/// MyHomePage({Key key}) : super(key: key);
///
/// @override
/// _MyHomePageState createState() => _MyHomePageState();
/// }
///
/// class _MyHomePageState extends State<MyHomePage> {
/// int _selectedIndex = 1;
/// final _widgetOptions = [
/// Text('Index 0: Home'),
/// Text('Index 1: Business'),
/// Text('Index 2: School'),
/// ];
///
/// @override
/// Widget build(BuildContext context) {
/// return Scaffold(
/// appBar: AppBar(
/// title: Text('BottomNavigationBarImpl Sample'),
/// ),
/// body: Center(
/// child: _widgetOptions.elementAt(_selectedIndex),
/// ),
/// bottomNavigationBar: BottomNavigationBar(
/// items: <BottomNavigationBarItem>[
/// BottomNavigationBarItem(icon: Icon(Icons.home)), //you don't need a title param
/// BottomNavigationBarItem(icon: Icon(Icons.business)),
/// BottomNavigationBarItem(icon: Icon(Icons.school)),
/// ],
/// currentIndex: _selectedIndex,
/// fixedColor: Colors.deepPurple,
/// onTap: _onItemTapped,
/// ),
/// );
/// }
///
/// void _onItemTapped(int index) {
/// setState(() {
/// _selectedIndex = index;
/// });
/// }
/// }
/// ```
///
/// See also:
///
/// * [BottomNavigationBarItem]
/// * [Scaffold]
/// * <https://material.google.com/components/bottom-navigation.html>
class BottomNavigationBarImpl extends StatefulWidget {
/// Creates a bottom navigation bar, typically used in a [Scaffold] where it
/// is provided as the [Scaffold.bottomNavigationBar] argument.
///
/// The length of [items] must be at least two and each item's icon must be not null.
///
/// If [type] is null then [BottomNavigationBarTypeImpl.fixed] is used when there
/// are two or three [items], [BottomNavigationBarTypeImpl.shifting] otherwise.
///
/// If [fixedColor] is null then the theme's primary color,
/// [ThemeData.primaryColor], is used. However if [BottomNavigationBar.type] is
/// [BottomNavigationBarTypeImpl.shifting] then [fixedColor] is ignored.
BottomNavigationBarImpl({
Key key,
@required this.items,
this.onTap,
this.currentIndex = 0,
BottomNavigationBarTypeImpl type,
this.fixedColor,
this.iconSize = 24.0,
}) : assert(items != null),
assert(items.length >= 2),
assert(0 <= currentIndex && currentIndex < items.length),
assert(iconSize != null),
type = type ?? (items.length <= 3 ? BottomNavigationBarTypeImpl.fixed : BottomNavigationBarTypeImpl.shifting),
super(key: key);
/// The interactive items laid out within the bottom navigation bar where each item has an icon.
final List<BottomNavigationBarItem> items;
/// The callback that is called when a item 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 [items] of the current active item.
final int currentIndex;
/// Defines the layout and behavior of a [BottomNavigationBar].
///
/// See documentation for [BottomNavigationBarTypeImpl] for information on the meaning
/// of different types.
final BottomNavigationBarTypeImpl type;
/// The color of the selected item when bottom navigation bar is
/// [BottomNavigationBarTypeImpl.fixed].
///
/// If [fixedColor] is null then the theme's primary color,
/// [ThemeData.primaryColor], is used. However if [BottomNavigationBar.type] is
/// [BottomNavigationBarTypeImpl.shifting] then [fixedColor] is ignored.
final Color fixedColor;
/// The size of all of the [BottomNavigationBarItem] icons.
///
/// See [BottomNavigationBarItem.icon] for more information.
final double iconSize;
@override
_BottomNavigationBarState createState() => _BottomNavigationBarState();
}
// This represents a single tile in the bottom navigation bar. It is intended
// to go into a flex container.
class _BottomNavigationTile extends StatelessWidget {
const _BottomNavigationTile(
this.type,
this.item,
this.animation,
this.iconSize, {
this.onTap,
this.colorTween,
this.flex,
this.selected = false,
this.indexLabel,
}) : assert(selected != null);
final BottomNavigationBarTypeImpl type;
final BottomNavigationBarItem item;
final Animation<double> animation;
final double iconSize;
final VoidCallback onTap;
final ColorTween colorTween;
final double flex;
final bool selected;
final String indexLabel;
Widget _buildIcon() {
Color iconColor;
switch (type) {
case BottomNavigationBarTypeImpl.fixed:
iconColor = colorTween.evaluate(animation);
break;
case BottomNavigationBarTypeImpl.shifting:
iconColor = Colors.white;
break;
}
return Align(
alignment: Alignment.topCenter,
child: Container(
child: IconTheme(
data: IconThemeData(
color: iconColor,
size: iconSize,
),
child: selected ? item.activeIcon : item.icon,
),
),
);
}
@override
Widget build(BuildContext context) {
// In order to use the flex container to grow the tile during animation, we
// need to divide the changes in flex allotment into smaller pieces to
// produce smooth animation. We do this by multiplying the flex value
// (which is an integer) by a large number.
int size;
switch (type) {
case BottomNavigationBarTypeImpl.fixed:
size = 1;
break;
case BottomNavigationBarTypeImpl.shifting:
size = (flex * 1000.0).round();
break;
}
return Expanded(
flex: size,
child: Semantics(
container: true,
header: true,
selected: selected,
child: Stack(
children: <Widget>[
InkResponse(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_buildIcon(),
],
),
),
],
),
),
);
}
}
class _BottomNavigationBarState extends State<BottomNavigationBarImpl> with TickerProviderStateMixin {
List<AnimationController> _controllers = <AnimationController>[];
List<CurvedAnimation> _animations;
// A queue of color splashes currently being animated.
final Queue<_Circle> _circles = Queue<_Circle>();
// Last splash circle's color, and the final color of the control after
// animation is complete.
Color _backgroundColor;
static final Animatable<double> _flexTween = Tween<double>(begin: 1.0, end: 1.5);
void _resetState() {
for (AnimationController controller in _controllers)
controller.dispose();
for (_Circle circle in _circles)
circle.dispose();
_circles.clear();
_controllers = List<AnimationController>.generate(widget.items.length, (int index) {
return AnimationController(
duration: kThemeAnimationDuration,
vsync: this,
)..addListener(_rebuild);
});
_animations = List<CurvedAnimation>.generate(widget.items.length, (int index) {
return CurvedAnimation(
parent: _controllers[index],
curve: Curves.fastOutSlowIn,
reverseCurve: Curves.fastOutSlowIn.flipped,
);
});
_controllers[widget.currentIndex].value = 1.0;
_backgroundColor = widget.items[widget.currentIndex].backgroundColor;
}
@override
void initState() {
super.initState();
_resetState();
}
void _rebuild() {
setState(() {
// Rebuilding when any of the controllers tick, i.e. when the items are
// animated.
});
}
@override
void dispose() {
for (AnimationController controller in _controllers)
controller.dispose();
for (_Circle circle in _circles)
circle.dispose();
super.dispose();
}
double _evaluateFlex(Animation<double> animation) => _flexTween.evaluate(animation);
void _pushCircle(int index) {
if (widget.items[index].backgroundColor != null) {
_circles.add(
_Circle(
state: this,
index: index,
color: widget.items[index].backgroundColor,
vsync: this,
)..controller.addStatusListener(
(AnimationStatus status) {
switch (status) {
case AnimationStatus.completed:
setState(() {
final _Circle circle = _circles.removeFirst();
_backgroundColor = circle.color;
circle.dispose();
});
break;
case AnimationStatus.dismissed:
case AnimationStatus.forward:
case AnimationStatus.reverse:
break;
}
},
),
);
}
}
@override
void didUpdateWidget(BottomNavigationBarImpl oldWidget) {
super.didUpdateWidget(oldWidget);
// No animated segue if the length of the items list changes.
if (widget.items.length != oldWidget.items.length) {
_resetState();
return;
}
if (widget.currentIndex != oldWidget.currentIndex) {
switch (widget.type) {
case BottomNavigationBarTypeImpl.fixed:
break;
case BottomNavigationBarTypeImpl.shifting:
_pushCircle(widget.currentIndex);
break;
}
_controllers[oldWidget.currentIndex].reverse();
_controllers[widget.currentIndex].forward();
} else {
if (_backgroundColor != widget.items[widget.currentIndex].backgroundColor)
_backgroundColor = widget.items[widget.currentIndex].backgroundColor;
}
}
List<Widget> _createTiles() {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
assert(localizations != null);
final List<Widget> children = <Widget>[];
switch (widget.type) {
case BottomNavigationBarTypeImpl.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),
),
);
}
break;
case BottomNavigationBarTypeImpl.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),
),
);
}
break;
}
return children;
}
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));
assert(debugCheckHasMaterialLocalizations(context));
Color backgroundColor;
switch (widget.type) {
case BottomNavigationBarTypeImpl.fixed:
break;
case BottomNavigationBarTypeImpl.shifting:
backgroundColor = _backgroundColor;
break;
}
return Semantics(
container: true,
explicitChildNodes: true,
child: Stack(
children: <Widget>[
Positioned.fill(
child: Material( // Casts shadow.
elevation: 8.0,
color: backgroundColor,
),
),
ConstrainedBox(
constraints: BoxConstraints(minHeight: kBottomNavigationBarHeight + 0),
child: Stack(
children: <Widget>[
Positioned.fill(
child: CustomPaint(
painter: _RadialPainter(
circles: _circles.toList(),
textDirection: Directionality.of(context),
),
),
),
Material( // Splashes.
type: MaterialType.card,
child: Container(
child: _createContainer(_createTiles()),
height: kBottomNavigationBarHeight,
),
),
],
),
),
],
),
);
}
}
// Describes an animating color splash circle.
class _Circle {
_Circle({
@required this.state,
@required this.index,
@required this.color,
@required TickerProvider vsync,
}) : assert(state != null),
assert(index != null),
assert(color != null) {
controller = AnimationController(
duration: kThemeAnimationDuration,
vsync: vsync,
);
animation = CurvedAnimation(
parent: controller,
curve: Curves.fastOutSlowIn,
);
controller.forward();
}
final _BottomNavigationBarState state;
final int index;
final Color color;
AnimationController controller;
CurvedAnimation animation;
double get horizontalLeadingOffset {
double weightSum(Iterable<Animation<double>> animations) {
// We're adding flex values instead of animation values to produce correct
// ratios.
return animations.map<double>(state._evaluateFlex).fold<double>(0.0, (double sum, double value) => sum + value);
}
final double allWeights = weightSum(state._animations);
// These weights sum to the start edge of the indexed item.
final double leadingWeights = weightSum(state._animations.sublist(0, index));
// Add half of its flex value in order to get to the center.
return (leadingWeights + state._evaluateFlex(state._animations[index]) / 2.0) / allWeights;
}
void dispose() {
controller.dispose();
}
}
// Paints the animating color splash circles.
class _RadialPainter extends CustomPainter {
_RadialPainter({
@required this.circles,
@required this.textDirection,
}) : assert(circles != null),
assert(textDirection != null);
final List<_Circle> circles;
final TextDirection textDirection;
// Computes the maximum radius attainable such that at least one of the
// bounding rectangle's corners touches the edge of the circle. Drawing a
// circle larger than this radius is not needed, since there is no perceivable
// difference within the cropped rectangle.
static double _maxRadius(Offset center, Size size) {
final double maxX = math.max(center.dx, size.width - center.dx);
final double maxY = math.max(center.dy, size.height - center.dy);
return math.sqrt(maxX * maxX + maxY * maxY);
}
@override
bool shouldRepaint(_RadialPainter oldPainter) {
if (textDirection != oldPainter.textDirection)
return true;
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 Paint paint = Paint()..color = circle.color;
final Rect rect = Rect.fromLTWH(0.0, 0.0, size.width, size.height);
canvas.clipRect(rect);
double leftFraction;
switch (textDirection) {
case TextDirection.rtl:
leftFraction = 1.0 - circle.horizontalLeadingOffset;
break;
case TextDirection.ltr:
leftFraction = circle.horizontalLeadingOffset;
break;
}
final Offset center = Offset(leftFraction * size.width, size.height / 2.0);
final Tween<double> radiusTween = Tween<double>(
begin: 0.0,
end: _maxRadius(center, size),
);
canvas.drawCircle(
center,
radiusTween.transform(circle.animation.value),
paint,
);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment