bottom_navigation_bar.dart 19 KB
Newer Older
1 2 3 4 5 6 7
// 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;

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

import 'colors.dart';
import 'constants.dart';
import 'icon_theme.dart';
import 'icon_theme_data.dart';
import 'ink_well.dart';
import 'material.dart';
import 'theme.dart';
19
import 'typography.dart';
20 21 22 23 24 25 26 27 28

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

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

35
  /// The location and size of the [BottomNavigationBar] [BottomNavigationBarItem]s
36 37 38 39 40 41 42 43 44 45 46
  /// 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>
47 48
class BottomNavigationBarItem {
  /// Creates an item that is used with [BottomNavigationBar.items].
49 50
  ///
  /// The arguments [icon] and [title] should not be null.
51
  BottomNavigationBarItem({
52 53 54 55 56 57 58 59
    @required this.icon,
    @required this.title,
    this.backgroundColor
  }) {
    assert(this.icon != null);
    assert(this.title != null);
  }

60
  /// The icon of the item.
61 62 63 64 65
  ///
  /// Typically the icon is an [Icon] or an [IconImage] widget. If another type
  /// of widget is provided then it should configure itself to match the current
  /// [IconTheme] size and color.
  final Widget icon;
66

67
  /// The title of the item.
68 69 70 71 72
  final Widget title;

  /// The color of the background radial animation.
  ///
  /// If the navigation bar's type is [BottomNavigationBarType.shifting], then
73
  /// the entire bar is flooded with the [backgroundColor] when this item is
74 75 76 77 78 79 80
  /// tapped.
  final Color backgroundColor;
}

/// A material widget displayed at the bottom of an app for selecting among a
/// small number of views.
///
81 82 83 84
/// 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.
85 86 87 88 89 90
///
/// A bottom navigation bar is usually used in conjunction with [Scaffold] where
/// it is provided as the [Scaffold.bottomNavigationBar] argument.
///
/// See also:
///
91
///  * [BottomNavigationBarItem]
92 93 94 95 96 97
///  * [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.
  ///
98
  /// The arguments [items] and [type] should not be null.
99
  ///
100
  /// The number of items passed should be equal or greater than 2.
101 102 103 104 105
  ///
  /// Passing a null [fixedColor] will cause a fallback to the theme's primary
  /// color.
  BottomNavigationBar({
    Key key,
106
    @required this.items,
107 108 109
    this.onTap,
    this.currentIndex: 0,
    this.type: BottomNavigationBarType.fixed,
110 111
    this.fixedColor,
    this.iconSize: 24.0,
112
  }) : super(key: key) {
113 114 115
    assert(items != null);
    assert(items.length >= 2);
    assert(0 <= currentIndex && currentIndex < items.length);
116 117 118
    assert(type != null);
    assert(type == BottomNavigationBarType.fixed || fixedColor == null);
    assert(iconSize != null);
119 120
  }

121 122
  /// The interactive items laid out within the bottom navigation bar.
  final List<BottomNavigationBarItem> items;
123

124
  /// The callback that is called when a item is tapped.
125 126 127 128 129 130
  ///
  /// 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;

131
  /// The index into [items] of the current active item.
132 133 134 135 136
  final int currentIndex;

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

137
  /// The color of the selected item when bottom navigation bar is
138 139 140
  /// [BottomNavigationBarType.fixed].
  final Color fixedColor;

141
  /// The size of all of the [BottomNavigationBarItem] icons.
142 143
  ///
  /// This value is used to to configure the [IconTheme] for the navigation
144
  /// bar. When a [BottomNavigationBarItem.icon] widget is not an [Icon] the widget
145 146 147
  /// should configure itself to match the icon theme's size and color.
  final double iconSize;

148 149 150 151
  @override
  BottomNavigationBarState createState() => new BottomNavigationBarState();
}

152
class BottomNavigationBarState extends State<BottomNavigationBar> with TickerProviderStateMixin {
153 154 155 156 157 158 159 160 161 162 163 164 165 166
  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();
167
    _controllers = new List<AnimationController>.generate(config.items.length, (int index) {
168
      return new AnimationController(
169 170
        duration: kThemeAnimationDuration,
        vsync: this,
171 172
      )..addListener(_rebuild);
    });
173
    animations = new List<CurvedAnimation>.generate(config.items.length, (int index) {
174 175 176 177 178 179 180
      return new CurvedAnimation(
        parent: _controllers[index],
        curve: Curves.fastOutSlowIn,
        reverseCurve: Curves.fastOutSlowIn.flipped
      );
    });
    _controllers[config.currentIndex].value = 1.0;
181
    _backgroundColor = config.items[config.currentIndex].backgroundColor;
182 183 184 185 186 187 188 189 190 191 192 193 194
  }

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

  void _rebuild() {
    setState(() {
195
      // Rebuilding when any of the controllers tick, i.e. when the items are
196 197 198 199 200 201 202 203
      // animated.
    });
  }

  double get _maxWidth {
    assert(config.type != null);
    switch (config.type) {
      case BottomNavigationBarType.fixed:
204
        return config.items.length * _kActiveMaxWidth;
205
      case BottomNavigationBarType.shifting:
206
        return _kActiveMaxWidth + (config.items.length - 1) * _kInactiveMaxWidth;
207 208 209 210 211 212 213 214 215 216 217 218
    }
    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)
219
  // This causes instability in the animation when multiple items are tapped.
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256
  // 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);
257
    // This weight corresponds to the left edge of the indexed item.
258 259 260 261 262 263
    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;
  }

264 265
  FractionalOffset _circleOffset(int index) {
    final double iconSize = config.iconSize;
266 267 268 269 270 271 272 273 274 275 276 277
    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) {
278
    if (config.items[index].backgroundColor != null)
279 280 281 282
      _circles.add(
        new _Circle(
          state: this,
          index: index,
283
          color: config.items[index].backgroundColor,
284
          vsync: this,
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313
        )..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);
314
        final TextTheme textTheme = themeData.textTheme;
315
        final ColorTween colorTween = new ColorTween(
316 317 318 319 320
          begin: textTheme.caption.color,
          end: config.fixedColor ?? (
            themeData.brightness == Brightness.light ?
                themeData.primaryColor : themeData.accentColor
          )
321
        );
322
        for (int i = 0; i < config.items.length; i += 1) {
323
          children.add(
324
            new Expanded(
325 326 327 328 329 330 331 332 333 334 335 336 337 338
              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,
339 340
                            end: 6.0,
                          ).evaluate(animations[i]),
341 342 343
                        ),
                        child: new IconTheme(
                          data: new IconThemeData(
344
                            color: colorTween.evaluate(animations[i]),
345
                            size: config.iconSize,
346
                          ),
347
                          child: config.items[i].icon,
348 349
                        ),
                      ),
350 351 352 353 354
                    ),
                    new Align(
                      alignment: FractionalOffset.bottomCenter,
                      child: new Container(
                        margin: const EdgeInsets.only(bottom: 10.0),
355 356
                        child: new DefaultTextStyle.merge(
                          context: context,
357 358
                          style: new TextStyle(
                            fontSize: 14.0,
359
                            color: colorTween.evaluate(animations[i]),
360 361 362 363 364 365
                          ),
                          child: new Transform(
                            transform: new Matrix4.diagonal3(new Vector3.all(
                              new Tween<double>(
                                begin: 0.85,
                                end: 1.0,
366
                              ).evaluate(animations[i]),
367 368
                            )),
                            alignment: FractionalOffset.bottomCenter,
369
                            child: config.items[i].title,
370 371 372 373 374 375 376 377
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
378 379 380 381
          );
        }
        bottomNavigation = new SizedBox(
          width: _maxWidth,
382
          child: new Row(children: children),
383 384
        );
        break;
385

386 387 388
      case BottomNavigationBarType.shifting:
        final List<Widget> children = <Widget>[];
        _computeWeight();
389
        for (int i = 0; i < config.items.length; i += 1) {
390
          children.add(
391
            new Expanded(
392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408
              // 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,
409 410
                            end: 6.0,
                          ).evaluate(animations[i]),
411 412 413
                        ),
                        child: new IconTheme(
                          data: new IconThemeData(
414 415
                            color: Colors.white,
                            size: config.iconSize,
416
                          ),
417
                          child: config.items[i].icon,
418 419
                        ),
                      ),
420 421 422 423 424 425 426
                    ),
                    new Align(
                      alignment: FractionalOffset.bottomCenter,
                      child: new Container(
                        margin: const EdgeInsets.only(bottom: 10.0),
                        child: new FadeTransition(
                          opacity: animations[i],
427 428
                          child: new DefaultTextStyle.merge(
                            context: context,
429
                            style: const TextStyle(
430 431 432
                              fontSize: 14.0,
                              color: Colors.white
                            ),
433
                            child: config.items[i].title
434 435 436 437 438 439 440 441
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457
          );
        }
        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,
458
            color: config.type == BottomNavigationBarType.shifting ? _backgroundColor : null
459 460 461 462 463 464 465 466 467 468 469 470 471 472
          )
        ),
        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(
473 474
                      circles: _circles.toList(),
                      bottomNavMaxWidth: _maxWidth,
475 476
                    ),
                  ),
477 478 479 480 481
                ),
                new Material( // Splashes.
                  type: MaterialType.transparency,
                  child: new Center(
                    child: bottomNavigation
482 483 484 485 486 487 488
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
489 490 491 492 493 494 495 496
    );
  }
}

class _Circle {
  _Circle({
    this.state,
    this.index,
497 498
    this.color,
    @required TickerProvider vsync,
499 500 501 502 503 504
  }) {
    assert(this.state != null);
    assert(this.index != null);
    assert(this.color != null);

    controller = new AnimationController(
505 506
      duration: kThemeAnimationDuration,
      vsync: vsync,
507 508 509 510 511 512 513 514 515 516 517 518 519 520 521
    );
    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 {
522
    return state._circleOffset(index);
523 524 525 526 527 528 529 530 531
  }

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

class _RadialPainter extends CustomPainter {
  _RadialPainter({
532 533
    this.circles,
    this.bottomNavMaxWidth,
534 535 536
  });

  final List<_Circle> circles;
537
  final double bottomNavMaxWidth;
538 539 540 541 542 543 544 545 546 547 548 549 550 551 552

  // 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) {
553 554 555
    if (bottomNavMaxWidth != oldPainter.bottomNavMaxWidth)
      return true;

556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577
    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);
578 579 580 581 582
      double navWidth = math.min(bottomNavMaxWidth, size.width);
      Point center = new Point(
        (size.width - navWidth) / 2.0 + circle.offset.dx * navWidth,
        circle.offset.dy * size.height
      );
583
      canvas.drawCircle(
584
        center,
585 586 587 588 589 590
        radiusTween.lerp(circle.animation.value),
        paint
      );
    }
  }
}