bottom_navigation_bar.dart 17.8 KB
Newer Older
1 2 3 4 5
// 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;
6
import 'dart:math' as math;
7

8
import 'package:flutter/foundation.dart';
9 10 11 12 13 14 15 16
import 'package:flutter/widgets.dart';
import 'package:vector_math/vector_math_64.dart' show Vector3;

import 'colors.dart';
import 'constants.dart';
import 'ink_well.dart';
import 'material.dart';
import 'theme.dart';
17
import 'typography.dart';
18 19 20 21 22 23 24 25 26

const double _kActiveMaxWidth = 168.0;
const double _kInactiveMaxWidth = 96.0;

/// Defines the layout and behavior of a [BottomNavigationBar].
///
/// See also:
///
///  * [BottomNavigationBar]
27
///  * [BottomNavigationBarItem]
28 29
///  * <https://material.google.com/components/bottom-navigation.html#bottom-navigation-specs>
enum BottomNavigationBarType {
30
  /// The [BottomNavigationBar]'s [BottomNavigationBarItem]s have fixed width.
31 32
  fixed,

33
  /// The location and size of the [BottomNavigationBar] [BottomNavigationBarItem]s
34 35 36 37 38 39 40
  /// animate larger when they are tapped.
  shifting,
}

/// A material widget displayed at the bottom of an app for selecting among a
/// small number of views.
///
41 42 43 44
/// The bottom navigation bar consists of multiple items in the form of
/// 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.
45 46 47 48 49 50
///
/// A bottom navigation bar is usually used in conjunction with [Scaffold] where
/// it is provided as the [Scaffold.bottomNavigationBar] argument.
///
/// See also:
///
51
///  * [BottomNavigationBarItem]
52 53 54 55 56 57
///  * [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.
  ///
58
  /// The arguments [items] and [type] should not be null.
59
  ///
60
  /// The number of items passed should be equal or greater than 2.
61 62 63 64 65
  ///
  /// Passing a null [fixedColor] will cause a fallback to the theme's primary
  /// color.
  BottomNavigationBar({
    Key key,
66
    @required this.items,
67 68 69
    this.onTap,
    this.currentIndex: 0,
    this.type: BottomNavigationBarType.fixed,
70 71
    this.fixedColor,
    this.iconSize: 24.0,
72 73 74 75 76 77 78
  }) : assert(items != null),
       assert(items.length >= 2),
       assert(0 <= currentIndex && currentIndex < items.length),
       assert(type != null),
       assert(type == BottomNavigationBarType.fixed || fixedColor == null),
       assert(iconSize != null),
       super(key: key);
79

80 81
  /// The interactive items laid out within the bottom navigation bar.
  final List<BottomNavigationBarItem> items;
82

83
  /// The callback that is called when a item is tapped.
84 85 86 87 88 89
  ///
  /// 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;

90
  /// The index into [items] of the current active item.
91 92 93 94 95
  final int currentIndex;

  /// Defines the layout and behavior of a [BottomNavigationBar].
  final BottomNavigationBarType type;

96
  /// The color of the selected item when bottom navigation bar is
97 98 99
  /// [BottomNavigationBarType.fixed].
  final Color fixedColor;

100
  /// The size of all of the [BottomNavigationBarItem] icons.
101 102
  ///
  /// This value is used to to configure the [IconTheme] for the navigation
103
  /// bar. When a [BottomNavigationBarItem.icon] widget is not an [Icon] the widget
104 105 106
  /// should configure itself to match the icon theme's size and color.
  final double iconSize;

107
  @override
108
  _BottomNavigationBarState createState() => new _BottomNavigationBarState();
109 110
}

111
class _BottomNavigationBarState extends State<BottomNavigationBar> with TickerProviderStateMixin {
112
  List<AnimationController> _controllers;
113
  List<CurvedAnimation> _animations;
114 115 116 117 118 119 120 121 122 123 124 125
  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();
126
    _controllers = new List<AnimationController>.generate(widget.items.length, (int index) {
127
      return new AnimationController(
128 129
        duration: kThemeAnimationDuration,
        vsync: this,
130 131
      )..addListener(_rebuild);
    });
132
    _animations = new List<CurvedAnimation>.generate(widget.items.length, (int index) {
133 134 135 136 137 138
      return new CurvedAnimation(
        parent: _controllers[index],
        curve: Curves.fastOutSlowIn,
        reverseCurve: Curves.fastOutSlowIn.flipped
      );
    });
139 140
    _controllers[widget.currentIndex].value = 1.0;
    _backgroundColor = widget.items[widget.currentIndex].backgroundColor;
141 142 143 144 145 146 147 148 149 150 151 152 153
  }

  @override
  void dispose() {
    for (AnimationController controller in _controllers)
      controller.dispose();
    for (_Circle circle in _circles)
      circle.dispose();
    super.dispose();
  }

  void _rebuild() {
    setState(() {
154
      // Rebuilding when any of the controllers tick, i.e. when the items are
155 156 157 158 159
      // animated.
    });
  }

  double get _maxWidth {
160 161
    assert(widget.type != null);
    switch (widget.type) {
162
      case BottomNavigationBarType.fixed:
163
        return widget.items.length * _kActiveMaxWidth;
164
      case BottomNavigationBarType.shifting:
165
        return _kActiveMaxWidth + (widget.items.length - 1) * _kInactiveMaxWidth;
166 167 168 169 170 171 172 173 174 175 176 177
    }
    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)
178
  // This causes instability in the animation when multiple items are tapped.
179 180 181 182
  // 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() {
183
    final Iterable<Animation<double>> animating = _animations.where(_isAnimating);
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205

    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) {
206 207
      // We're adding flex values instead of animation values to have correct ratios.
      return animations.map(_flex).fold(0.0, (double sum, double value) => sum + value);
208 209
    }

210
    final double allWeights = weightSum(_animations);
211
    // This weight corresponds to the left edge of the indexed item.
212
    final double leftWeights = weightSum(_animations.sublist(0, index));
213 214

    // Add half of its flex value in order to get the center.
215
    return (leftWeights + _flex(_animations[index]) / 2.0) / allWeights;
216 217
  }

218
  FractionalOffset _circleOffset(int index) {
219
    final double iconSize = widget.iconSize;
220 221 222 223 224 225 226
    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),
227
      yOffsetTween.evaluate(_animations[index])
228 229 230 231
    );
  }

  void _pushCircle(int index) {
232
    if (widget.items[index].backgroundColor != null)
233 234 235 236
      _circles.add(
        new _Circle(
          state: this,
          index: index,
237
          color: widget.items[index].backgroundColor,
238
          vsync: this,
239 240 241
        )..controller.addStatusListener((AnimationStatus status) {
          if (status == AnimationStatus.completed) {
            setState(() {
242
              final _Circle circle = _circles.removeFirst();
243 244 245 246 247 248 249 250 251
              _backgroundColor = circle.color;
              circle.dispose();
            });
          }
        })
      );
  }

  @override
252
  void didUpdateWidget(BottomNavigationBar oldWidget) {
253
    super.didUpdateWidget(oldWidget);
254 255 256 257 258
    if (widget.currentIndex != oldWidget.currentIndex) {
      if (widget.type == BottomNavigationBarType.shifting)
        _pushCircle(widget.currentIndex);
      _controllers[oldWidget.currentIndex].reverse();
      _controllers[widget.currentIndex].forward();
259 260 261 262 263 264
    }
  }

  @override
  Widget build(BuildContext context) {
    Widget bottomNavigation;
265
    switch (widget.type) {
266 267 268
      case BottomNavigationBarType.fixed:
        final List<Widget> children = <Widget>[];
        final ThemeData themeData = Theme.of(context);
269
        final TextTheme textTheme = themeData.textTheme;
270
        final ColorTween colorTween = new ColorTween(
271
          begin: textTheme.caption.color,
272
          end: widget.fixedColor ?? (
273 274 275
            themeData.brightness == Brightness.light ?
                themeData.primaryColor : themeData.accentColor
          )
276
        );
277
        for (int i = 0; i < widget.items.length; i += 1) {
278
          children.add(
279
            new Expanded(
280 281
              child: new InkResponse(
                onTap: () {
282 283
                  if (widget.onTap != null)
                    widget.onTap(i);
284 285 286 287 288 289 290 291 292 293
                },
                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,
294
                            end: 6.0,
295
                          ).evaluate(_animations[i]),
296 297 298
                        ),
                        child: new IconTheme(
                          data: new IconThemeData(
299
                            color: colorTween.evaluate(_animations[i]),
300
                            size: widget.iconSize,
301
                          ),
302
                          child: widget.items[i].icon,
303 304
                        ),
                      ),
305 306 307 308 309
                    ),
                    new Align(
                      alignment: FractionalOffset.bottomCenter,
                      child: new Container(
                        margin: const EdgeInsets.only(bottom: 10.0),
310
                        child: DefaultTextStyle.merge(
311 312
                          style: new TextStyle(
                            fontSize: 14.0,
313
                            color: colorTween.evaluate(_animations[i]),
314 315 316 317 318 319
                          ),
                          child: new Transform(
                            transform: new Matrix4.diagonal3(new Vector3.all(
                              new Tween<double>(
                                begin: 0.85,
                                end: 1.0,
320
                              ).evaluate(_animations[i]),
321 322
                            )),
                            alignment: FractionalOffset.bottomCenter,
323
                            child: widget.items[i].title,
324 325 326 327 328 329 330 331
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
332 333 334 335
          );
        }
        bottomNavigation = new SizedBox(
          width: _maxWidth,
336
          child: new Row(children: children),
337 338
        );
        break;
339

340 341 342
      case BottomNavigationBarType.shifting:
        final List<Widget> children = <Widget>[];
        _computeWeight();
343
        for (int i = 0; i < widget.items.length; i += 1) {
344
          children.add(
345
            new Expanded(
346 347
              // Since Flexible only supports integers, we're using large
              // numbers in order to simulate floating point flex values.
348
              flex: (_flex(_animations[i]) * 1000.0).round(),
349 350
              child: new InkResponse(
                onTap: () {
351 352
                  if (widget.onTap != null)
                    widget.onTap(i);
353 354 355 356 357 358 359 360 361 362
                },
                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,
363
                            end: 6.0,
364
                          ).evaluate(_animations[i]),
365 366 367
                        ),
                        child: new IconTheme(
                          data: new IconThemeData(
368
                            color: Colors.white,
369
                            size: widget.iconSize,
370
                          ),
371
                          child: widget.items[i].icon,
372 373
                        ),
                      ),
374 375 376 377 378 379
                    ),
                    new Align(
                      alignment: FractionalOffset.bottomCenter,
                      child: new Container(
                        margin: const EdgeInsets.only(bottom: 10.0),
                        child: new FadeTransition(
380
                          opacity: _animations[i],
381
                          child: DefaultTextStyle.merge(
382
                            style: const TextStyle(
383 384 385
                              fontSize: 14.0,
                              color: Colors.white
                            ),
386
                            child: widget.items[i].title
387 388 389 390 391 392 393 394
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409
          );
        }
        bottomNavigation = new SizedBox(
          width: _maxWidth,
          child: new Row(
            children: children
          )
        );
        break;
    }

    return new Stack(
      children: <Widget>[
        new Positioned.fill(
          child: new Material( // Casts shadow.
410
            elevation: 8.0,
411
            color: widget.type == BottomNavigationBarType.shifting ? _backgroundColor : null
412 413 414 415 416 417 418 419 420 421 422 423 424 425
          )
        ),
        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(
426 427
                      circles: _circles.toList(),
                      bottomNavMaxWidth: _maxWidth,
428 429
                    ),
                  ),
430 431 432 433 434
                ),
                new Material( // Splashes.
                  type: MaterialType.transparency,
                  child: new Center(
                    child: bottomNavigation
435 436 437 438 439 440 441
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
442 443 444 445 446 447
    );
  }
}

class _Circle {
  _Circle({
448 449 450
    @required this.state,
    @required this.index,
    @required this.color,
451
    @required TickerProvider vsync,
452 453 454
  }) : assert(state != null),
       assert(index != null),
       assert(color != null) {
455
    controller = new AnimationController(
456 457
      duration: kThemeAnimationDuration,
      vsync: vsync,
458 459 460 461 462 463 464 465
    );
    animation = new CurvedAnimation(
      parent: controller,
      curve: Curves.fastOutSlowIn
    );
    controller.forward();
  }

466
  final _BottomNavigationBarState state;
467 468 469 470 471 472
  final int index;
  final Color color;
  AnimationController controller;
  CurvedAnimation animation;

  FractionalOffset get offset {
473
    return state._circleOffset(index);
474 475 476 477 478 479 480 481 482
  }

  void dispose() {
    controller.dispose();
  }
}

class _RadialPainter extends CustomPainter {
  _RadialPainter({
483 484
    this.circles,
    this.bottomNavMaxWidth,
485 486 487
  });

  final List<_Circle> circles;
488
  final double bottomNavMaxWidth;
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503

  // 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) {
504 505 506
    if (bottomNavMaxWidth != oldPainter.bottomNavMaxWidth)
      return true;

507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528
    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);
529
      final double navWidth = math.min(bottomNavMaxWidth, size.width);
530
      final Offset center = new Offset(
531 532 533
        (size.width - navWidth) / 2.0 + circle.offset.dx * navWidth,
        circle.offset.dy * size.height
      );
534
      canvas.drawCircle(
535
        center,
536 537 538 539 540 541
        radiusTween.lerp(circle.animation.value),
        paint
      );
    }
  }
}