bottom_navigation_bar.dart 36 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5
// 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/widgets.dart';
9
import 'package:flutter/rendering.dart';
10 11
import 'package:vector_math/vector_math_64.dart' show Vector3;

12
import 'bottom_navigation_bar_theme.dart';
13
import 'constants.dart';
14
import 'debug.dart';
15 16
import 'ink_well.dart';
import 'material.dart';
17
import 'material_localizations.dart';
18
import 'text_theme.dart';
19
import 'theme.dart';
20
import 'tooltip.dart';
21 22 23 24 25 26

/// Defines the layout and behavior of a [BottomNavigationBar].
///
/// See also:
///
///  * [BottomNavigationBar]
27
///  * [BottomNavigationBarItem]
28
///  * <https://material.io/design/components/bottom-navigation.html#specs>
29
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
  /// animate and labels fade in when they are tapped.
35 36 37
  shifting,
}

38 39
/// 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.
40
///
41
/// The bottom navigation bar consists of multiple items in the form of
42 43 44
/// 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.
45
///
46 47
/// A bottom navigation bar is usually used in conjunction with a [Scaffold],
/// where it is provided as the [Scaffold.bottomNavigationBar] argument.
48
///
49
/// The bottom navigation bar's [type] changes how its [items] are displayed.
50 51 52
/// If not specified, then it's automatically set to
/// [BottomNavigationBarType.fixed] when there are less than four items, and
/// [BottomNavigationBarType.shifting] otherwise.
53 54
///
///  * [BottomNavigationBarType.fixed], the default when there are less than
55 56 57 58
///    four [items]. The selected item is rendered with the
///    [selectedItemColor] if it's non-null, otherwise the theme's
///    [ThemeData.primaryColor] is used. If [backgroundColor] is null, The
///    navigation bar's background color defaults to the [Material] background
59 60
///    color, [ThemeData.canvasColor] (essentially opaque white).
///  * [BottomNavigationBarType.shifting], the default when there are four
61 62
///    or more [items]. If [selectedItemColor] is null, all items are rendered
///    in white. The navigation bar's background color is the same as the
63 64 65 66
///    [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.
///
67
/// {@tool dartpad --template=stateful_widget_material}
68 69
/// This example shows a [BottomNavigationBar] as it is used within a [Scaffold]
/// widget. The [BottomNavigationBar] has three [BottomNavigationBarItem]
70
/// widgets and the [currentIndex] is set to index 0. The selected item is
71
/// amber. The `_onItemTapped` function changes the selected item's index
72
/// and displays a corresponding message in the center of the [Scaffold].
73
///
74 75 76
/// ![A scaffold with a bottom navigation bar containing three bottom navigation
/// bar items. The first one is selected.](https://flutter.github.io/assets-for-api-docs/assets/material/bottom_navigation_bar.png)
///
77
/// ```dart
78
/// int _selectedIndex = 0;
79 80 81 82 83 84 85 86 87 88 89 90 91 92
/// static const TextStyle optionStyle = TextStyle(fontSize: 30, fontWeight: FontWeight.bold);
/// static const List<Widget> _widgetOptions = <Widget>[
///   Text(
///     'Index 0: Home',
///     style: optionStyle,
///   ),
///   Text(
///      'Index 1: Business',
///      style: optionStyle,
///   ),
///   Text(
///      'Index 2: School',
///      style: optionStyle,
///   ),
93
/// ];
94
///
95 96 97 98
/// void _onItemTapped(int index) {
///   setState(() {
///     _selectedIndex = index;
///   });
99 100
/// }
///
101 102 103 104
/// @override
/// Widget build(BuildContext context) {
///   return Scaffold(
///     appBar: AppBar(
105
///       title: const Text('BottomNavigationBar Sample'),
106 107 108 109 110
///     ),
///     body: Center(
///       child: _widgetOptions.elementAt(_selectedIndex),
///     ),
///     bottomNavigationBar: BottomNavigationBar(
111 112 113
///       items: const <BottomNavigationBarItem>[
///         BottomNavigationBarItem(
///           icon: Icon(Icons.home),
114
///           label: 'Home',
115 116 117
///         ),
///         BottomNavigationBarItem(
///           icon: Icon(Icons.business),
118
///           label: 'Business',
119 120 121
///         ),
///         BottomNavigationBarItem(
///           icon: Icon(Icons.school),
122
///           label: 'School',
123
///         ),
124 125
///       ],
///       currentIndex: _selectedIndex,
126
///       selectedItemColor: Colors.amber[800],
127 128 129
///       onTap: _onItemTapped,
///     ),
///   );
130 131
/// }
/// ```
132
/// {@end-tool}
133
///
134 135
/// See also:
///
136
///  * [BottomNavigationBarItem]
137
///  * [Scaffold]
138
///  * <https://material.io/design/components/bottom-navigation.html>
139
class BottomNavigationBar extends StatefulWidget {
140 141
  /// Creates a bottom navigation bar which is typically used as a
  /// [Scaffold]'s [Scaffold.bottomNavigationBar] argument.
142
  ///
143
  /// The length of [items] must be at least two and each item's icon and label
144
  /// must not be null.
145
  ///
146 147
  /// If [type] is null then [BottomNavigationBarType.fixed] is used when there
  /// are two or three [items], [BottomNavigationBarType.shifting] otherwise.
148
  ///
149 150 151
  /// The [iconSize], [selectedFontSize], [unselectedFontSize], and [elevation]
  /// arguments must be non-null and non-negative.
  ///
152 153 154 155
  /// If [selectedLabelStyle.color] and [unselectedLabelStyle.color] values
  /// are non-null, they will be used instead of [selectedItemColor] and
  /// [unselectedItemColor].
  ///
156
  /// If custom [IconThemeData]s are used, you must provide both
157 158 159 160 161 162
  /// [selectedIconTheme] and [unselectedIconTheme], and both
  /// [IconThemeData.color] and [IconThemeData.size] must be set.
  ///
  /// If both [selectedLabelStyle.fontSize] and [selectedFontSize] are set,
  /// [selectedLabelStyle.fontSize] will be used.
  ///
163 164 165 166
  /// Only one of [selectedItemColor] and [fixedColor] can be specified. The
  /// former is preferred, [fixedColor] only exists for the sake of
  /// backwards compatibility.
  ///
167 168 169
  /// If [showSelectedLabels] is `null`, [BottomNavigationBarThemeData.showSelectedLabels]
  /// is used. If [BottomNavigationBarThemeData.showSelectedLabels]  is null,
  /// then [showSelectedLabels] defaults to `true`.
170
  ///
171 172 173 174
  /// If [showUnselectedLabels] is `null`, [BottomNavigationBarThemeData.showUnselectedLabels]
  /// is used. If [BottomNavigationBarThemeData.showSelectedLabels] is null,
  /// then [showUnselectedLabels] defaults to `true` when [type] is
  /// [BottomNavigationBarType.fixed] and `false` when [type] is
175
  /// [BottomNavigationBarType.shifting].
176
  BottomNavigationBar({
177 178
    Key? key,
    required this.items,
179
    this.onTap,
180
    this.currentIndex = 0,
181 182
    this.elevation,
    this.type,
183
    Color? fixedColor,
184
    this.backgroundColor,
185
    this.iconSize = 24.0,
186
    Color? selectedItemColor,
187
    this.unselectedItemColor,
188 189
    this.selectedIconTheme,
    this.unselectedIconTheme,
190 191
    this.selectedFontSize = 14.0,
    this.unselectedFontSize = 12.0,
192 193
    this.selectedLabelStyle,
    this.unselectedLabelStyle,
194
    this.showSelectedLabels,
195
    this.showUnselectedLabels,
196
    this.mouseCursor,
197 198
  }) : assert(items != null),
       assert(items.length >= 2),
199
       assert(
200 201 202
        items.every((BottomNavigationBarItem item) => item.title != null) ||
        items.every((BottomNavigationBarItem item) => item.label != null),
        'Every item must have a non-null title or label',
203
       ),
204
       assert(0 <= currentIndex && currentIndex < items.length),
205
       assert(elevation == null || elevation >= 0.0),
206 207
       assert(iconSize != null && iconSize >= 0.0),
       assert(
208
         selectedItemColor == null || fixedColor == null,
209 210 211 212 213
         'Either selectedItemColor or fixedColor can be specified, but not both'
       ),
       assert(selectedFontSize != null && selectedFontSize >= 0.0),
       assert(unselectedFontSize != null && unselectedFontSize >= 0.0),
       selectedItemColor = selectedItemColor ?? fixedColor,
214
       super(key: key);
215

216 217
  /// Defines the appearance of the button items that are arrayed within the
  /// bottom navigation bar.
218
  final List<BottomNavigationBarItem> items;
219

220
  /// Called when one of the [items] is tapped.
221
  ///
222 223 224
  /// The stateful widget that creates the bottom navigation bar needs to keep
  /// track of the index of the selected [BottomNavigationBarItem] and call
  /// `setState` to rebuild the bottom navigation bar with the new [currentIndex].
225
  final ValueChanged<int>? onTap;
226

227
  /// The index into [items] for the current active [BottomNavigationBarItem].
228 229
  final int currentIndex;

230 231 232 233 234
  /// The z-coordinate of this [BottomNavigationBar].
  ///
  /// If null, defaults to `8.0`.
  ///
  /// {@macro flutter.material.material.elevation}
235
  final double? elevation;
236

237
  /// Defines the layout and behavior of a [BottomNavigationBar].
238
  ///
239 240
  /// See documentation for [BottomNavigationBarType] for information on the
  /// meaning of different types.
241
  final BottomNavigationBarType? type;
242

243 244 245 246
  /// The value of [selectedItemColor].
  ///
  /// This getter only exists for backwards compatibility, the
  /// [selectedItemColor] property is preferred.
247
  Color? get fixedColor => selectedItemColor;
248 249

  /// The color of the [BottomNavigationBar] itself.
250
  ///
251
  /// If [type] is [BottomNavigationBarType.shifting] and the
252
  /// [items] have [BottomNavigationBarItem.backgroundColor] set, the [items]'
253
  /// backgroundColor will splash and overwrite this color.
254
  final Color? backgroundColor;
255

256
  /// The size of all of the [BottomNavigationBarItem] icons.
257
  ///
258
  /// See [BottomNavigationBarItem.icon] for more information.
259 260
  final double iconSize;

261
  /// The color of the selected [BottomNavigationBarItem.icon] and
262
  /// [BottomNavigationBarItem.title].
263 264
  ///
  /// If null then the [ThemeData.primaryColor] is used.
265
  final Color? selectedItemColor;
266 267

  /// The color of the unselected [BottomNavigationBarItem.icon] and
268
  /// [BottomNavigationBarItem.title]s.
269 270
  ///
  /// If null then the [TextTheme.caption]'s color is used.
271
  final Color? unselectedItemColor;
272

273 274 275 276 277 278 279 280 281
  /// The size, opacity, and color of the icon in the currently selected
  /// [BottomNavigationBarItem.icon].
  ///
  /// If this is not provided, the size will default to [iconSize], the color
  /// will default to [selectedItemColor].
  ///
  /// It this field is provided, it must contain non-null [IconThemeData.size]
  /// and [IconThemeData.color] properties. Also, if this field is supplied,
  /// [unselectedIconTheme] must be provided.
282
  final IconThemeData? selectedIconTheme;
283 284

  /// The size, opacity, and color of the icon in the currently unselected
285
  /// [BottomNavigationBarItem.icon]s.
286 287 288 289 290 291
  ///
  /// If this is not provided, the size will default to [iconSize], the color
  /// will default to [unselectedItemColor].
  ///
  /// It this field is provided, it must contain non-null [IconThemeData.size]
  /// and [IconThemeData.color] properties. Also, if this field is supplied,
292
  /// [selectedIconTheme] must be provided.
293
  final IconThemeData? unselectedIconTheme;
294 295 296

  /// The [TextStyle] of the [BottomNavigationBarItem] labels when they are
  /// selected.
297
  final TextStyle? selectedLabelStyle;
298 299 300

  /// The [TextStyle] of the [BottomNavigationBarItem] labels when they are not
  /// selected.
301
  final TextStyle? unselectedLabelStyle;
302

303 304
  /// The font size of the [BottomNavigationBarItem] labels when they are selected.
  ///
305 306
  /// If [TextStyle.fontSize] of [selectedLabelStyle] is non-null, it will be
  /// used instead of this.
307
  ///
308 309 310 311 312 313
  /// Defaults to `14.0`.
  final double selectedFontSize;

  /// The font size of the [BottomNavigationBarItem] labels when they are not
  /// selected.
  ///
314 315
  /// If [TextStyle.fontSize] of [unselectedLabelStyle] is non-null, it will be
  /// used instead of this.
316
  ///
317 318 319
  /// Defaults to `12.0`.
  final double unselectedFontSize;

320
  /// Whether the labels are shown for the unselected [BottomNavigationBarItem]s.
321
  final bool? showUnselectedLabels;
322

323
  /// Whether the labels are shown for the selected [BottomNavigationBarItem].
324
  final bool? showSelectedLabels;
325

326 327 328 329
  /// The cursor for a mouse pointer when it enters or is hovering over the
  /// tiles.
  ///
  /// If this property is null, [SystemMouseCursors.click] will be used.
330
  final MouseCursor? mouseCursor;
331

332
  @override
333
  _BottomNavigationBarState createState() => _BottomNavigationBarState();
334 335
}

336 337 338
// This represents a single tile in the bottom navigation bar. It is intended
// to go into a flex container.
class _BottomNavigationTile extends StatelessWidget {
339
  const _BottomNavigationTile(
340 341 342 343 344 345
    this.type,
    this.item,
    this.animation,
    this.iconSize, {
    this.onTap,
    this.colorTween,
346
    this.flex,
347
    this.selected = false,
348 349 350 351
    required this.selectedLabelStyle,
    required this.unselectedLabelStyle,
    required this.selectedIconTheme,
    required this.unselectedIconTheme,
352 353
    required this.showSelectedLabels,
    required this.showUnselectedLabels,
354
    this.indexLabel,
355
    required this.mouseCursor,
356 357 358 359
    }) : assert(type != null),
         assert(item != null),
         assert(animation != null),
         assert(selected != null),
360
         assert(selectedLabelStyle != null),
361 362
         assert(unselectedLabelStyle != null),
         assert(mouseCursor != null);
363 364 365 366 367

  final BottomNavigationBarType type;
  final BottomNavigationBarItem item;
  final Animation<double> animation;
  final double iconSize;
368 369 370
  final VoidCallback? onTap;
  final ColorTween? colorTween;
  final double? flex;
371
  final bool selected;
372 373
  final IconThemeData? selectedIconTheme;
  final IconThemeData? unselectedIconTheme;
374 375
  final TextStyle selectedLabelStyle;
  final TextStyle unselectedLabelStyle;
376
  final String? indexLabel;
377 378
  final bool showSelectedLabels;
  final bool showUnselectedLabels;
379
  final MouseCursor mouseCursor;
380

381 382 383 384 385 386
  @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.
387
    final int size;
388

389
    final double selectedFontSize = selectedLabelStyle.fontSize!;
390

391 392
    final double selectedIconSize = selectedIconTheme?.size ?? iconSize;
    final double unselectedIconSize = unselectedIconTheme?.size ?? iconSize;
393

394 395 396 397 398 399
    // The amount that the selected icon is bigger than the unselected icons,
    // (or zero if the selected icon is not bigger than the unselected icons).
    final double selectedIconDiff = math.max(selectedIconSize - unselectedIconSize, 0);
    // The amount that the unselected icons are bigger than the selected icon,
    // (or zero if the unselected icons are not any bigger than the selected icon).
    final double unselectedIconDiff = math.max(unselectedIconSize - selectedIconSize, 0);
400 401 402 403 404

    // Defines the padding for the animating icons + labels.
    //
    // The animations go from "Unselected":
    // =======
405
    // |      <-- Padding equal to the text height + 1/2 selectedIconDiff.
406
    // |  ☆
407
    // | text <-- Invisible text + padding equal to 1/2 selectedIconDiff.
408 409 410 411 412
    // =======
    //
    // To "Selected":
    //
    // =======
413
    // |      <-- Padding equal to 1/2 text height + 1/2 unselectedIconDiff.
414 415
    // |  ☆
    // | text
416
    // |      <-- Padding equal to 1/2 text height + 1/2 unselectedIconDiff.
417
    // =======
418 419 420
    double bottomPadding;
    double topPadding;
    if (showSelectedLabels && !showUnselectedLabels) {
421
      bottomPadding = Tween<double>(
422 423
        begin: selectedIconDiff / 2.0,
        end: selectedFontSize / 2.0 - unselectedIconDiff / 2.0,
424 425
      ).evaluate(animation);
      topPadding = Tween<double>(
426 427 428
        begin: selectedFontSize + selectedIconDiff / 2.0,
        end: selectedFontSize / 2.0 - unselectedIconDiff / 2.0,
      ).evaluate(animation);
429
    } else if (!showSelectedLabels && !showUnselectedLabels) {
430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445
      bottomPadding = Tween<double>(
        begin: selectedIconDiff / 2.0,
        end: unselectedIconDiff / 2.0,
      ).evaluate(animation);
      topPadding = Tween<double>(
        begin: selectedFontSize + selectedIconDiff / 2.0,
        end: selectedFontSize + unselectedIconDiff / 2.0,
      ).evaluate(animation);
    } else {
      bottomPadding = Tween<double>(
        begin: selectedFontSize / 2.0 + selectedIconDiff / 2.0,
        end: selectedFontSize / 2.0 + unselectedIconDiff / 2.0,
      ).evaluate(animation);
      topPadding = Tween<double>(
        begin: selectedFontSize / 2.0 + selectedIconDiff / 2.0,
        end: selectedFontSize / 2.0 + unselectedIconDiff / 2.0,
446 447
      ).evaluate(animation);
    }
448 449 450 451 452 453

    switch (type) {
      case BottomNavigationBarType.fixed:
        size = 1;
        break;
      case BottomNavigationBarType.shifting:
454
        size = (flex! * 1000.0).round();
455 456 457
        break;
    }

458 459 460 461 462 463 464 465 466
    Widget result = InkResponse(
      onTap: onTap,
      mouseCursor: mouseCursor,
      child: Padding(
        padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          mainAxisSize: MainAxisSize.min,
467
          children: <Widget>[
468
            _TileIcon(
469
              colorTween: colorTween!,
470 471 472 473
              animation: animation,
              iconSize: iconSize,
              selected: selected,
              item: item,
474 475
              selectedIconTheme: selectedIconTheme,
              unselectedIconTheme: unselectedIconTheme,
476
            ),
477
            _Label(
478
              colorTween: colorTween!,
479 480
              animation: animation,
              item: item,
481 482
              selectedLabelStyle: selectedLabelStyle,
              unselectedLabelStyle: unselectedLabelStyle,
483 484
              showSelectedLabels: showSelectedLabels,
              showUnselectedLabels: showUnselectedLabels,
485 486
            ),
          ],
487 488 489
        ),
      ),
    );
490 491 492

    if (item.label != null) {
      result = Tooltip(
493
        message: item.label!,
494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516
        preferBelow: false,
        verticalOffset: selectedIconSize + selectedFontSize,
        child: result,
      );
    }

    result = Semantics(
      selected: selected,
      container: true,
      child: Stack(
        children: <Widget>[
          result,
          Semantics(
            label: indexLabel,
          ),
        ],
      ),
    );

    return Expanded(
      flex: size,
      child: result,
    );
517 518 519 520 521 522
  }
}


class _TileIcon extends StatelessWidget {
  const _TileIcon({
523 524 525 526 527 528 529 530
    Key? key,
    required this.colorTween,
    required this.animation,
    required this.iconSize,
    required this.selected,
    required this.item,
    required this.selectedIconTheme,
    required this.unselectedIconTheme,
531 532 533
  }) : assert(selected != null),
       assert(item != null),
       super(key: key);
534 535 536 537 538 539

  final ColorTween colorTween;
  final Animation<double> animation;
  final double iconSize;
  final bool selected;
  final BottomNavigationBarItem item;
540 541
  final IconThemeData? selectedIconTheme;
  final IconThemeData? unselectedIconTheme;
542 543 544

  @override
  Widget build(BuildContext context) {
545
    final Color? iconColor = colorTween.evaluate(animation);
546 547 548 549 550 551 552 553 554 555
    final IconThemeData defaultIconTheme = IconThemeData(
      color: iconColor,
      size: iconSize,
    );
    final IconThemeData iconThemeData = IconThemeData.lerp(
      defaultIconTheme.merge(unselectedIconTheme),
      defaultIconTheme.merge(selectedIconTheme),
      animation.value,
    );

556
    return Align(
557 558
      alignment: Alignment.topCenter,
      heightFactor: 1.0,
559 560
      child: Container(
        child: IconTheme(
561
          data: iconThemeData,
562
          child: selected ? item.activeIcon : item.icon,
563 564 565 566
        ),
      ),
    );
  }
567
}
568

569 570
class _Label extends StatelessWidget {
  const _Label({
571 572 573 574 575 576 577 578
    Key? key,
    required this.colorTween,
    required this.animation,
    required this.item,
    required this.selectedLabelStyle,
    required this.unselectedLabelStyle,
    required this.showSelectedLabels,
    required this.showUnselectedLabels,
579 580 581
  }) : assert(colorTween != null),
       assert(animation != null),
       assert(item != null),
582 583
       assert(selectedLabelStyle != null),
       assert(unselectedLabelStyle != null),
584 585 586
       assert(showSelectedLabels != null),
       assert(showUnselectedLabels != null),
       super(key: key);
587 588 589 590

  final ColorTween colorTween;
  final Animation<double> animation;
  final BottomNavigationBarItem item;
591 592
  final TextStyle selectedLabelStyle;
  final TextStyle unselectedLabelStyle;
593 594
  final bool showSelectedLabels;
  final bool showUnselectedLabels;
595 596 597

  @override
  Widget build(BuildContext context) {
598 599
    final double? selectedFontSize = selectedLabelStyle.fontSize;
    final double? unselectedFontSize = unselectedLabelStyle.fontSize;
600 601 602 603 604

    final TextStyle customStyle = TextStyle.lerp(
      unselectedLabelStyle,
      selectedLabelStyle,
      animation.value,
605
    )!;
606
    Widget text = DefaultTextStyle.merge(
607
      style: customStyle.copyWith(
608 609 610 611 612 613 614 615 616 617
        fontSize: selectedFontSize,
        color: colorTween.evaluate(animation),
      ),
      // The font size should grow here when active, but because of the way
      // font rendering works, it doesn't grow smoothly if we just animate
      // the font size, so we use a transform instead.
      child: Transform(
        transform: Matrix4.diagonal3(
          Vector3.all(
            Tween<double>(
618
              begin: unselectedFontSize! / selectedFontSize!,
619 620
              end: 1.0,
            ).evaluate(animation),
621 622
          ),
        ),
623
        alignment: Alignment.bottomCenter,
624
        child: item.title ?? Text(item.label!),
625 626 627
      ),
    );

628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649
    if (!showUnselectedLabels && !showSelectedLabels) {
      // Never show any labels.
      text = Opacity(
        alwaysIncludeSemantics: true,
        opacity: 0.0,
        child: text,
      );
    } else if (!showUnselectedLabels) {
      // Fade selected labels in.
      text = FadeTransition(
        alwaysIncludeSemantics: true,
        opacity: animation,
        child: text,
      );
    } else if (!showSelectedLabels) {
      // Fade selected labels out.
      text = FadeTransition(
        alwaysIncludeSemantics: true,
        opacity: Tween<double>(begin: 1.0, end: 0.0).animate(animation),
        child: text,
      );
    }
650

651
    text = Align(
652 653
      alignment: Alignment.bottomCenter,
      heightFactor: 1.0,
654
      child: Container(child: text),
655
    );
656 657 658 659

    if (item.label != null) {
      // Do not grow text in bottom navigation bar when we can show a tooltip
      // instead.
660
      final MediaQueryData mediaQueryData = MediaQuery.of(context);
661 662 663 664 665 666 667 668 669
      text = MediaQuery(
        data: mediaQueryData.copyWith(
          textScaleFactor: math.min(1.0, mediaQueryData.textScaleFactor),
        ),
        child: text,
      );
    }

    return text;
670 671 672
  }
}

673
class _BottomNavigationBarState extends State<BottomNavigationBar> with TickerProviderStateMixin {
674
  List<AnimationController> _controllers = <AnimationController>[];
675
  late List<CurvedAnimation> _animations;
676 677

  // A queue of color splashes currently being animated.
678
  final Queue<_Circle> _circles = Queue<_Circle>();
679

680 681
  // Last splash circle's color, and the final color of the control after
  // animation is complete.
682
  Color? _backgroundColor;
683

684
  static final Animatable<double> _flexTween = Tween<double>(begin: 1.0, end: 1.5);
685

686
  void _resetState() {
687
    for (final AnimationController controller in _controllers)
688
      controller.dispose();
689
    for (final _Circle circle in _circles)
690 691 692
      circle.dispose();
    _circles.clear();

693 694
    _controllers = List<AnimationController>.generate(widget.items.length, (int index) {
      return AnimationController(
695 696
        duration: kThemeAnimationDuration,
        vsync: this,
697 698
      )..addListener(_rebuild);
    });
699 700
    _animations = List<CurvedAnimation>.generate(widget.items.length, (int index) {
      return CurvedAnimation(
701 702
        parent: _controllers[index],
        curve: Curves.fastOutSlowIn,
703
        reverseCurve: Curves.fastOutSlowIn.flipped,
704 705
      );
    });
706 707
    _controllers[widget.currentIndex].value = 1.0;
    _backgroundColor = widget.items[widget.currentIndex].backgroundColor;
708 709
  }

710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734
  // Computes the default value for the [type] parameter.
  //
  // If type is provided, it is returned. Next, if the bottom navigation bar
  // theme provides a type, it is used. Finally, the default behavior will be
  // [BottomNavigationBarType.fixed] for 3 or fewer items, and
  // [BottomNavigationBarType.shifting] is used for 4+ items.
  BottomNavigationBarType get _effectiveType {
    return widget.type
      ?? BottomNavigationBarTheme.of(context).type
      ?? (widget.items.length <= 3 ? BottomNavigationBarType.fixed : BottomNavigationBarType.shifting);
  }

  // Computes the default value for the [showUnselected] parameter.
  //
  // Unselected labels are shown by default for [BottomNavigationBarType.fixed],
  // and hidden by default for [BottomNavigationBarType.shifting].
  bool get _defaultShowUnselected {
    switch (_effectiveType) {
      case BottomNavigationBarType.shifting:
        return false;
      case BottomNavigationBarType.fixed:
        return true;
    }
  }

735 736 737 738 739 740
  @override
  void initState() {
    super.initState();
    _resetState();
  }

741 742
  void _rebuild() {
    setState(() {
743
      // Rebuilding when any of the controllers tick, i.e. when the items are
744 745 746 747
      // animated.
    });
  }

748 749
  @override
  void dispose() {
750
    for (final AnimationController controller in _controllers)
751
      controller.dispose();
752
    for (final _Circle circle in _circles)
753 754
      circle.dispose();
    super.dispose();
755 756
  }

757
  double _evaluateFlex(Animation<double> animation) => _flexTween.evaluate(animation);
758 759

  void _pushCircle(int index) {
760
    if (widget.items[index].backgroundColor != null) {
761
      _circles.add(
762
        _Circle(
763 764
          state: this,
          index: index,
765
          color: widget.items[index].backgroundColor!,
766
          vsync: this,
767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783
        )..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;
            }
          },
        ),
784
      );
785
    }
786 787 788
  }

  @override
789
  void didUpdateWidget(BottomNavigationBar oldWidget) {
790
    super.didUpdateWidget(oldWidget);
791 792 793 794 795 796 797

    // No animated segue if the length of the items list changes.
    if (widget.items.length != oldWidget.items.length) {
      _resetState();
      return;
    }

798
    if (widget.currentIndex != oldWidget.currentIndex) {
799
      switch (_effectiveType) {
800 801 802 803 804 805
        case BottomNavigationBarType.fixed:
          break;
        case BottomNavigationBarType.shifting:
          _pushCircle(widget.currentIndex);
          break;
      }
806 807
      _controllers[oldWidget.currentIndex].reverse();
      _controllers[widget.currentIndex].forward();
808 809 810
    } else {
      if (_backgroundColor != widget.items[widget.currentIndex].backgroundColor)
        _backgroundColor = widget.items[widget.currentIndex].backgroundColor;
811 812 813
    }
  }

814 815
  // If the given [TextStyle] has a non-null `fontSize`, it should be used.
  // Otherwise, the [selectedFontSize] parameter should be used.
816
  static TextStyle _effectiveTextStyle(TextStyle? textStyle, double fontSize) {
817
    textStyle ??= const TextStyle();
818 819 820 821
    // Prefer the font size on textStyle if present.
    return textStyle.fontSize == null ? textStyle.copyWith(fontSize: fontSize) : textStyle;
  }

822
  List<Widget> _createTiles() {
823
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
824
    assert(localizations != null);
825

826
    final ThemeData themeData = Theme.of(context);
827
    final BottomNavigationBarThemeData bottomTheme = BottomNavigationBarTheme.of(context);
828

829
    final TextStyle effectiveSelectedLabelStyle =
830 831 832 833
      _effectiveTextStyle(
        widget.selectedLabelStyle ?? bottomTheme.selectedLabelStyle,
        widget.selectedFontSize,
      );
834
    final TextStyle effectiveUnselectedLabelStyle =
835 836 837 838
      _effectiveTextStyle(
        widget.unselectedLabelStyle ?? bottomTheme.unselectedLabelStyle,
        widget.unselectedFontSize,
      );
839

840
    final Color themeColor;
841 842 843 844 845 846 847 848 849
    switch (themeData.brightness) {
      case Brightness.light:
        themeColor = themeData.primaryColor;
        break;
      case Brightness.dark:
        themeColor = themeData.accentColor;
        break;
    }

850
    final ColorTween colorTween;
851
    switch (_effectiveType) {
852
      case BottomNavigationBarType.fixed:
853
        colorTween = ColorTween(
854 855
          begin: widget.unselectedItemColor
            ?? bottomTheme.unselectedItemColor
856
            ?? themeData.textTheme.caption!.color,
857 858 859 860
          end: widget.selectedItemColor
            ?? bottomTheme.selectedItemColor
            ?? widget.fixedColor
            ?? themeColor,
861 862 863
        );
        break;
      case BottomNavigationBarType.shifting:
864
        colorTween = ColorTween(
865 866 867 868 869 870
          begin: widget.unselectedItemColor
            ?? bottomTheme.unselectedItemColor
            ?? themeData.colorScheme.surface,
          end: widget.selectedItemColor
            ?? bottomTheme.selectedItemColor
            ?? themeData.colorScheme.surface,
871
        );
872 873
        break;
    }
874
    final MouseCursor effectiveMouseCursor = widget.mouseCursor ?? SystemMouseCursors.click;
875 876 877 878

    final List<Widget> tiles = <Widget>[];
    for (int i = 0; i < widget.items.length; i++) {
      tiles.add(_BottomNavigationTile(
879
        _effectiveType,
880 881 882
        widget.items[i],
        _animations[i],
        widget.iconSize,
883 884
        selectedIconTheme: widget.selectedIconTheme ?? bottomTheme.selectedIconTheme,
        unselectedIconTheme: widget.unselectedIconTheme ?? bottomTheme.unselectedIconTheme,
885 886
        selectedLabelStyle: effectiveSelectedLabelStyle,
        unselectedLabelStyle: effectiveUnselectedLabelStyle,
887 888
        onTap: () {
          if (widget.onTap != null)
889
            widget.onTap!(i);
890 891 892 893
        },
        colorTween: colorTween,
        flex: _evaluateFlex(_animations[i]),
        selected: i == widget.currentIndex,
894
        showSelectedLabels: widget.showSelectedLabels ?? bottomTheme.showSelectedLabels ?? true,
895
        showUnselectedLabels: widget.showUnselectedLabels ?? bottomTheme.showUnselectedLabels ?? _defaultShowUnselected,
896
        indexLabel: localizations.tabLabel(tabIndex: i + 1, tabCount: widget.items.length),
897
        mouseCursor: effectiveMouseCursor,
898 899 900
      ));
    }
    return tiles;
901 902 903 904 905
  }

  Widget _createContainer(List<Widget> tiles) {
    return DefaultTextStyle.merge(
      overflow: TextOverflow.ellipsis,
906
      child: Row(
907 908 909 910 911
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: tiles,
      ),
    );
  }
912

913 914
  @override
  Widget build(BuildContext context) {
915
    assert(debugCheckHasDirectionality(context));
916
    assert(debugCheckHasMaterialLocalizations(context));
917
    assert(debugCheckHasMediaQuery(context));
918
    assert(Overlay.of(context, debugRequiredFor: widget) != null);
919

920 921
    final BottomNavigationBarThemeData bottomTheme = BottomNavigationBarTheme.of(context);

922
    // Labels apply up to _bottomMargin padding. Remainder is media padding.
923
    final double additionalBottomPadding = math.max(MediaQuery.of(context).padding.bottom - widget.selectedFontSize / 2.0, 0.0);
924
    Color? backgroundColor;
925
    switch (_effectiveType) {
926
      case BottomNavigationBarType.fixed:
927
        backgroundColor = widget.backgroundColor ?? bottomTheme.backgroundColor;
928 929 930 931 932
        break;
      case BottomNavigationBarType.shifting:
        backgroundColor = _backgroundColor;
        break;
    }
933
    return Semantics(
934
      explicitChildNodes: true,
935
      child: Material(
936
        elevation: widget.elevation ?? bottomTheme.elevation ?? 8.0,
937 938 939 940 941 942
        color: backgroundColor,
        child: ConstrainedBox(
          constraints: BoxConstraints(minHeight: kBottomNavigationBarHeight + additionalBottomPadding),
          child: CustomPaint(
            painter: _RadialPainter(
              circles: _circles.toList(),
943
              textDirection: Directionality.of(context),
944
            ),
945 946 947 948 949 950 951 952
            child: Material( // Splashes.
              type: MaterialType.transparency,
              child: Padding(
                padding: EdgeInsets.only(bottom: additionalBottomPadding),
                child: MediaQuery.removePadding(
                  context: context,
                  removeBottom: true,
                  child: _createContainer(_createTiles()),
953
                ),
954
              ),
955
            ),
956
          ),
957
        ),
958
      ),
959 960 961 962
    );
  }
}

963
// Describes an animating color splash circle.
964 965
class _Circle {
  _Circle({
966 967 968 969
    required this.state,
    required this.index,
    required this.color,
    required TickerProvider vsync,
970 971 972
  }) : assert(state != null),
       assert(index != null),
       assert(color != null) {
973
    controller = AnimationController(
974 975
      duration: kThemeAnimationDuration,
      vsync: vsync,
976
    );
977
    animation = CurvedAnimation(
978
      parent: controller,
979
      curve: Curves.fastOutSlowIn,
980 981 982 983
    );
    controller.forward();
  }

984
  final _BottomNavigationBarState state;
985 986
  final int index;
  final Color color;
987 988
  late AnimationController controller;
  late CurvedAnimation animation;
989

990
  double get horizontalLeadingOffset {
991 992 993
    double weightSum(Iterable<Animation<double>> animations) {
      // We're adding flex values instead of animation values to produce correct
      // ratios.
994
      return animations.map<double>(state._evaluateFlex).fold<double>(0.0, (double sum, double value) => sum + value);
995 996 997
    }

    final double allWeights = weightSum(state._animations);
998 999
    // These weights sum to the start edge of the indexed item.
    final double leadingWeights = weightSum(state._animations.sublist(0, index));
1000 1001

    // Add half of its flex value in order to get to the center.
1002
    return (leadingWeights + state._evaluateFlex(state._animations[index]) / 2.0) / allWeights;
1003 1004 1005 1006 1007 1008 1009
  }

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

1010
// Paints the animating color splash circles.
1011 1012
class _RadialPainter extends CustomPainter {
  _RadialPainter({
1013 1014
    required this.circles,
    required this.textDirection,
1015 1016
  }) : assert(circles != null),
       assert(textDirection != null);
1017 1018

  final List<_Circle> circles;
1019
  final TextDirection textDirection;
1020 1021

  // Computes the maximum radius attainable such that at least one of the
1022 1023
  // 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
1024
  // difference within the cropped rectangle.
1025 1026 1027 1028
  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);
1029 1030 1031 1032
  }

  @override
  bool shouldRepaint(_RadialPainter oldPainter) {
1033 1034
    if (textDirection != oldPainter.textDirection)
      return true;
1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046
    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) {
1047
    for (final _Circle circle in circles) {
1048 1049
      final Paint paint = Paint()..color = circle.color;
      final Rect rect = Rect.fromLTWH(0.0, 0.0, size.width, size.height);
1050
      canvas.clipRect(rect);
1051
      final double leftFraction;
1052 1053 1054 1055 1056 1057 1058 1059
      switch (textDirection) {
        case TextDirection.rtl:
          leftFraction = 1.0 - circle.horizontalLeadingOffset;
          break;
        case TextDirection.ltr:
          leftFraction = circle.horizontalLeadingOffset;
          break;
      }
1060 1061
      final Offset center = Offset(leftFraction * size.width, size.height / 2.0);
      final Tween<double> radiusTween = Tween<double>(
1062 1063
        begin: 0.0,
        end: _maxRadius(center, size),
1064
      );
1065
      canvas.drawCircle(
1066
        center,
1067
        radiusTween.transform(circle.animation.value),
1068
        paint,
1069 1070 1071 1072
      );
    }
  }
}