bottom_navigation_bar.dart 45.7 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 9 10

import 'package:flutter/widgets.dart';
import 'package:vector_math/vector_math_64.dart' show Vector3;

11
import 'bottom_navigation_bar_theme.dart';
12
import 'constants.dart';
13
import 'debug.dart';
14 15
import 'ink_well.dart';
import 'material.dart';
16
import 'material_localizations.dart';
17
import 'material_state.dart';
18
import 'theme.dart';
19
import 'tooltip.dart';
20 21 22

/// Defines the layout and behavior of a [BottomNavigationBar].
///
23
/// For a sample on how to use these, please see [BottomNavigationBar].
24 25 26
/// 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 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
/// Refines the layout of a [BottomNavigationBar] when the enclosing
/// [MediaQueryData.orientation] is [Orientation.landscape].
enum BottomNavigationBarLandscapeLayout {
  /// If the enclosing [MediaQueryData.orientation] is
  /// [Orientation.landscape] then the navigation bar's items are
  /// evenly spaced and spread out across the available width. Each
  /// item's label and icon are arranged in a column.
  spread,

  /// If the enclosing [MediaQueryData.orientation] is
  /// [Orientation.landscape] then the navigation bar's items are
  /// evenly spaced in a row but only consume as much width as they
  /// would in portrait orientation. The row of items is centered within
  /// the available width. Each item's label and icon are arranged
  /// in a column.
  centered,

  /// If the enclosing [MediaQueryData.orientation] is
  /// [Orientation.landscape] then the navigation bar's items are
  /// evenly spaced and each item's icon and label are lined up in a
  /// row instead of a column.
  linear,
}


63 64
/// 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.
65
///
66
/// The bottom navigation bar consists of multiple items in the form of
67 68 69
/// 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.
70
///
71 72
/// A bottom navigation bar is usually used in conjunction with a [Scaffold],
/// where it is provided as the [Scaffold.bottomNavigationBar] argument.
73
///
74
/// The bottom navigation bar's [type] changes how its [items] are displayed.
75 76 77
/// If not specified, then it's automatically set to
/// [BottomNavigationBarType.fixed] when there are less than four items, and
/// [BottomNavigationBarType.shifting] otherwise.
78
///
79 80 81
/// The length of [items] must be at least two and each item's icon and title/label
/// must not be null.
///
82
///  * [BottomNavigationBarType.fixed], the default when there are less than
83 84
///    four [items]. The selected item is rendered with the
///    [selectedItemColor] if it's non-null, otherwise the theme's
85 86 87
///    [ColorScheme.primary] color is used for [Brightness.light] themes
///    and [ColorScheme.secondary] for [Brightness.dark] themes.
///    If [backgroundColor] is null, The
88
///    navigation bar's background color defaults to the [Material] background
89 90
///    color, [ThemeData.canvasColor] (essentially opaque white).
///  * [BottomNavigationBarType.shifting], the default when there are four
91 92
///    or more [items]. If [selectedItemColor] is null, all items are rendered
///    in white. The navigation bar's background color is the same as the
93 94 95 96
///    [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.
///
97
/// {@tool dartpad}
98 99
/// This example shows a [BottomNavigationBar] as it is used within a [Scaffold]
/// widget. The [BottomNavigationBar] has three [BottomNavigationBarItem]
100 101
/// widgets, which means it defaults to [BottomNavigationBarType.fixed], and
/// the [currentIndex] is set to index 0. The selected item is
102
/// amber. The `_onItemTapped` function changes the selected item's index
103
/// and displays a corresponding message in the center of the [Scaffold].
104
///
105
/// ** See code in examples/api/lib/material/bottom_navigation_bar/bottom_navigation_bar.0.dart **
106
/// {@end-tool}
107
///
108
/// {@tool dartpad}
109 110 111 112 113 114 115 116 117 118
/// This example shows a [BottomNavigationBar] as it is used within a [Scaffold]
/// widget. The [BottomNavigationBar] has four [BottomNavigationBarItem]
/// widgets, which means it defaults to [BottomNavigationBarType.shifting], and
/// the [currentIndex] is set to index 0. The selected item is amber in color.
/// With each [BottomNavigationBarItem] widget, backgroundColor property is
/// also defined, which changes the background color of [BottomNavigationBar],
/// when that item is selected. The `_onItemTapped` function changes the
/// selected item's index and displays a corresponding message in the center of
/// the [Scaffold].
///
119
/// ** See code in examples/api/lib/material/bottom_navigation_bar/bottom_navigation_bar.1.dart **
120
/// {@end-tool}
121 122 123 124 125 126 127 128 129
///
/// {@tool dartpad}
/// This example shows [BottomNavigationBar] used in a [Scaffold] Widget with
/// different interaction patterns. Tapping twice on the first [BottomNavigationBarItem]
/// uses the [ScrollController] to animate the [ListView] to the top. The second
/// [BottomNavigationBarItem] shows a Modal Dialog.
///
/// ** See code in examples/api/lib/material/bottom_navigation_bar/bottom_navigation_bar.2.dart **
/// {@end-tool}
130 131
/// See also:
///
132
///  * [BottomNavigationBarItem]
133
///  * [Scaffold]
134
///  * <https://material.io/design/components/bottom-navigation.html>
135
///  * [NavigationBar], this widget's replacement in Material Design 3.
136
class BottomNavigationBar extends StatefulWidget {
137 138
  /// Creates a bottom navigation bar which is typically used as a
  /// [Scaffold]'s [Scaffold.bottomNavigationBar] argument.
139
  ///
140
  /// The length of [items] must be at least two and each item's icon and label
141
  /// must not be null.
142
  ///
143 144
  /// If [type] is null then [BottomNavigationBarType.fixed] is used when there
  /// are two or three [items], [BottomNavigationBarType.shifting] otherwise.
145
  ///
146 147 148
  /// The [iconSize], [selectedFontSize], [unselectedFontSize], and [elevation]
  /// arguments must be non-null and non-negative.
  ///
149
  /// If [selectedLabelStyle].color and [unselectedLabelStyle].color values
150
  /// are non-null, they will be used instead of [selectedItemColor] and
151
  /// [unselectedItemColor].
152
  ///
153
  /// If custom [IconThemeData]s are used, you must provide both
154 155 156
  /// [selectedIconTheme] and [unselectedIconTheme], and both
  /// [IconThemeData.color] and [IconThemeData.size] must be set.
  ///
157 158 159 160 161
  /// If [useLegacyColorScheme] is set to `false`
  /// [selectedIconTheme] values will be used instead of [iconSize] and [selectedItemColor] for selected icons.
  /// [unselectedIconTheme] values will be used instead of [iconSize] and [unselectedItemColor] for unselected icons.
  ///
  ///
162 163
  /// If both [selectedLabelStyle].fontSize and [selectedFontSize] are set,
  /// [selectedLabelStyle].fontSize will be used.
164
  ///
165 166 167 168
  /// Only one of [selectedItemColor] and [fixedColor] can be specified. The
  /// former is preferred, [fixedColor] only exists for the sake of
  /// backwards compatibility.
  ///
169 170 171
  /// If [showSelectedLabels] is `null`, [BottomNavigationBarThemeData.showSelectedLabels]
  /// is used. If [BottomNavigationBarThemeData.showSelectedLabels]  is null,
  /// then [showSelectedLabels] defaults to `true`.
172
  ///
173 174 175 176
  /// 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
177
  /// [BottomNavigationBarType.shifting].
178
  BottomNavigationBar({
179
    super.key,
180
    required this.items,
181
    this.onTap,
182
    this.currentIndex = 0,
183 184
    this.elevation,
    this.type,
185
    Color? fixedColor,
186
    this.backgroundColor,
187
    this.iconSize = 24.0,
188
    Color? selectedItemColor,
189
    this.unselectedItemColor,
190 191
    this.selectedIconTheme,
    this.unselectedIconTheme,
192 193
    this.selectedFontSize = 14.0,
    this.unselectedFontSize = 12.0,
194 195
    this.selectedLabelStyle,
    this.unselectedLabelStyle,
196
    this.showSelectedLabels,
197
    this.showUnselectedLabels,
198
    this.mouseCursor,
199
    this.enableFeedback,
200
    this.landscapeLayout,
201
    this.useLegacyColorScheme = true,
202 203
  }) : assert(items != null),
       assert(items.length >= 2),
204
       assert(
205
        items.every((BottomNavigationBarItem item) => item.label != null),
206
        'Every item must have a non-null label',
207
       ),
208
       assert(0 <= currentIndex && currentIndex < items.length),
209
       assert(elevation == null || elevation >= 0.0),
210 211
       assert(iconSize != null && iconSize >= 0.0),
       assert(
212
         selectedItemColor == null || fixedColor == null,
213
         'Either selectedItemColor or fixedColor can be specified, but not both',
214 215 216
       ),
       assert(selectedFontSize != null && selectedFontSize >= 0.0),
       assert(unselectedFontSize != null && unselectedFontSize >= 0.0),
217
       selectedItemColor = selectedItemColor ?? fixedColor;
218

219 220
  /// Defines the appearance of the button items that are arrayed within the
  /// bottom navigation bar.
221
  final List<BottomNavigationBarItem> items;
222

223
  /// Called when one of the [items] is tapped.
224
  ///
225 226 227
  /// 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].
228
  final ValueChanged<int>? onTap;
229

230
  /// The index into [items] for the current active [BottomNavigationBarItem].
231 232
  final int currentIndex;

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

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

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

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

259
  /// The size of all of the [BottomNavigationBarItem] icons.
260
  ///
261
  /// See [BottomNavigationBarItem.icon] for more information.
262 263
  final double iconSize;

264
  /// The color of the selected [BottomNavigationBarItem.icon] and
265
  /// [BottomNavigationBarItem.label].
266 267
  ///
  /// If null then the [ThemeData.primaryColor] is used.
268
  final Color? selectedItemColor;
269 270

  /// The color of the unselected [BottomNavigationBarItem.icon] and
271
  /// [BottomNavigationBarItem.label]s.
272
  ///
273
  /// If null then the [ThemeData.unselectedWidgetColor]'s color is used.
274
  final Color? unselectedItemColor;
275

276 277 278 279 280 281 282 283 284
  /// 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.
285
  final IconThemeData? selectedIconTheme;
286 287

  /// The size, opacity, and color of the icon in the currently unselected
288
  /// [BottomNavigationBarItem.icon]s.
289 290 291 292 293 294
  ///
  /// 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,
295
  /// [selectedIconTheme] must be provided.
296
  final IconThemeData? unselectedIconTheme;
297 298 299

  /// The [TextStyle] of the [BottomNavigationBarItem] labels when they are
  /// selected.
300
  final TextStyle? selectedLabelStyle;
301 302 303

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

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

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

323
  /// Whether the labels are shown for the unselected [BottomNavigationBarItem]s.
324
  final bool? showUnselectedLabels;
325

326
  /// Whether the labels are shown for the selected [BottomNavigationBarItem].
327
  final bool? showSelectedLabels;
328

329
  /// The cursor for a mouse pointer when it enters or is hovering over the
330
  /// items.
331
  ///
332 333 334 335 336 337 338 339 340 341 342 343
  /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>],
  /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s:
  ///
  ///  * [MaterialState.selected].
  ///
  /// If null, then the value of [BottomNavigationBarThemeData.mouseCursor] is used. If
  /// that is also null, then [MaterialStateMouseCursor.clickable] is used.
  ///
  /// See also:
  ///
  ///  * [MaterialStateMouseCursor], which can be used to create a [MouseCursor]
  ///    that is also a [MaterialStateProperty<MouseCursor>].
344
  final MouseCursor? mouseCursor;
345

346 347 348 349 350 351 352 353 354 355
  /// Whether detected gestures should provide acoustic and/or haptic feedback.
  ///
  /// For example, on Android a tap will produce a clicking sound and a
  /// long-press will produce a short vibration, when feedback is enabled.
  ///
  /// See also:
  ///
  ///  * [Feedback] for providing platform-specific feedback to certain actions.
  final bool? enableFeedback;

356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385
  /// The arrangement of the bar's [items] when the enclosing
  /// [MediaQueryData.orientation] is [Orientation.landscape].
  ///
  /// The following alternatives are supported:
  ///
  /// * [BottomNavigationBarLandscapeLayout.spread] - the items are
  ///   evenly spaced and spread out across the available width. Each
  ///   item's label and icon are arranged in a column.
  /// * [BottomNavigationBarLandscapeLayout.centered] - the items are
  ///   evenly spaced in a row but only consume as much width as they
  ///   would in portrait orientation. The row of items is centered within
  ///   the available width. Each item's label and icon are arranged
  ///   in a column.
  /// * [BottomNavigationBarLandscapeLayout.linear] - the items are
  ///   evenly spaced and each item's icon and label are lined up in a
  ///   row instead of a column.
  ///
  /// If this property is null, then the value of the enclosing
  /// [BottomNavigationBarThemeData.landscapeLayout is used. If that
  /// property is also null, then
  /// [BottomNavigationBarLandscapeLayout.spread] is used.
  ///
  /// This property is null by default.
  ///
  /// See also:
  ///
  ///  * [ThemeData.bottomNavigationBarTheme] - which can be used to specify
  ///    bottom navigation bar defaults for an entire application.
  ///  * [BottomNavigationBarTheme] - which can be used to specify
  ///    bottom navigation bar defaults for a widget subtree.
386
  ///  * [MediaQuery.of] - which can be used to determine the current
387 388 389
  ///    orientation.
  final BottomNavigationBarLandscapeLayout? landscapeLayout;

390 391 392 393 394 395 396
  /// This flag is controlling how [BottomNavigationBar] is going to use
  /// the colors provided by the [selectedIconTheme], [unselectedIconTheme],
  /// [selectedItemColor], [unselectedItemColor].
  /// The default value is `true` as the new theming logic is a breaking change.
  /// To opt-in the new theming logic set the flag to `false`
  final bool useLegacyColorScheme;

397
  @override
398
  State<BottomNavigationBar> createState() => _BottomNavigationBarState();
399 400
}

401 402 403
// This represents a single tile in the bottom navigation bar. It is intended
// to go into a flex container.
class _BottomNavigationTile extends StatelessWidget {
404
  const _BottomNavigationTile(
405 406 407 408 409
    this.type,
    this.item,
    this.animation,
    this.iconSize, {
    this.onTap,
410 411
    this.labelColorTween,
    this.iconColorTween,
412
    this.flex,
413
    this.selected = false,
414 415 416 417
    required this.selectedLabelStyle,
    required this.unselectedLabelStyle,
    required this.selectedIconTheme,
    required this.unselectedIconTheme,
418 419
    required this.showSelectedLabels,
    required this.showUnselectedLabels,
420
    this.indexLabel,
421
    required this.mouseCursor,
422
    required this.enableFeedback,
423 424 425 426 427 428 429 430
    required this.layout,
  }) : assert(type != null),
       assert(item != null),
       assert(animation != null),
       assert(selected != null),
       assert(selectedLabelStyle != null),
       assert(unselectedLabelStyle != null),
       assert(mouseCursor != null);
431 432 433 434 435

  final BottomNavigationBarType type;
  final BottomNavigationBarItem item;
  final Animation<double> animation;
  final double iconSize;
436
  final VoidCallback? onTap;
437 438
  final ColorTween? labelColorTween;
  final ColorTween? iconColorTween;
439
  final double? flex;
440
  final bool selected;
441 442
  final IconThemeData? selectedIconTheme;
  final IconThemeData? unselectedIconTheme;
443 444
  final TextStyle selectedLabelStyle;
  final TextStyle unselectedLabelStyle;
445
  final String? indexLabel;
446 447
  final bool showSelectedLabels;
  final bool showUnselectedLabels;
448
  final MouseCursor mouseCursor;
449
  final bool enableFeedback;
450
  final BottomNavigationBarLandscapeLayout layout;
451

452 453 454 455 456 457
  @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.
458
    final int size;
459

460
    final double selectedFontSize = selectedLabelStyle.fontSize!;
461

462 463
    final double selectedIconSize = selectedIconTheme?.size ?? iconSize;
    final double unselectedIconSize = unselectedIconTheme?.size ?? iconSize;
464

465 466 467 468 469 470
    // 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);
471

472
    // The effective tool tip message to be shown on the BottomNavigationBarItem.
473
    final String? effectiveTooltip = item.tooltip == '' ? null : item.tooltip;
474

475 476 477 478
    // Defines the padding for the animating icons + labels.
    //
    // The animations go from "Unselected":
    // =======
479
    // |      <-- Padding equal to the text height + 1/2 selectedIconDiff.
480
    // |  ☆
481
    // | text <-- Invisible text + padding equal to 1/2 selectedIconDiff.
482 483 484 485 486
    // =======
    //
    // To "Selected":
    //
    // =======
487
    // |      <-- Padding equal to 1/2 text height + 1/2 unselectedIconDiff.
488 489
    // |  ☆
    // | text
490
    // |      <-- Padding equal to 1/2 text height + 1/2 unselectedIconDiff.
491
    // =======
492 493 494
    double bottomPadding;
    double topPadding;
    if (showSelectedLabels && !showUnselectedLabels) {
495
      bottomPadding = Tween<double>(
496 497
        begin: selectedIconDiff / 2.0,
        end: selectedFontSize / 2.0 - unselectedIconDiff / 2.0,
498 499
      ).evaluate(animation);
      topPadding = Tween<double>(
500 501 502
        begin: selectedFontSize + selectedIconDiff / 2.0,
        end: selectedFontSize / 2.0 - unselectedIconDiff / 2.0,
      ).evaluate(animation);
503
    } else if (!showSelectedLabels && !showUnselectedLabels) {
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519
      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,
520 521
      ).evaluate(animation);
    }
522 523 524 525 526 527

    switch (type) {
      case BottomNavigationBarType.fixed:
        size = 1;
        break;
      case BottomNavigationBarType.shifting:
528
        size = (flex! * 1000.0).round();
529 530 531
        break;
    }

532 533 534
    Widget result = InkResponse(
      onTap: onTap,
      mouseCursor: mouseCursor,
535
      enableFeedback: enableFeedback,
536 537
      child: Padding(
        padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding),
538 539 540
        child: _Tile(
          layout: layout,
          icon: _TileIcon(
541
            colorTween: iconColorTween!,
542 543 544 545 546 547 548 549
            animation: animation,
            iconSize: iconSize,
            selected: selected,
            item: item,
            selectedIconTheme: selectedIconTheme,
            unselectedIconTheme: unselectedIconTheme,
          ),
          label: _Label(
550
            colorTween: labelColorTween!,
551 552 553 554 555 556 557
            animation: animation,
            item: item,
            selectedLabelStyle: selectedLabelStyle,
            unselectedLabelStyle: unselectedLabelStyle,
            showSelectedLabels: showSelectedLabels,
            showUnselectedLabels: showUnselectedLabels,
          ),
558 559 560
        ),
      ),
    );
561

562
    if (effectiveTooltip != null) {
563
      result = Tooltip(
564
        message: effectiveTooltip,
565 566
        preferBelow: false,
        verticalOffset: selectedIconSize + selectedFontSize,
567
        excludeFromSemantics: true,
568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588
        child: result,
      );
    }

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

    return Expanded(
      flex: size,
      child: result,
    );
589 590 591 592
  }
}


593
// If the orientation is landscape and layout is
594 595 596 597 598 599 600 601
// BottomNavigationBarLandscapeLayout.linear then return a
// icon-space-label row, where space is 8 pixels. Otherwise return a
// icon-label column.
class _Tile extends StatelessWidget {
 const  _Tile({
    required this.layout,
    required this.icon,
    required this.label
602
  });
603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627

  final BottomNavigationBarLandscapeLayout layout;
  final Widget icon;
  final Widget label;

  @override
  Widget build(BuildContext context) {
    final MediaQueryData data = MediaQuery.of(context);
    if (data.orientation == Orientation.landscape && layout == BottomNavigationBarLandscapeLayout.linear) {
      return Align(
        heightFactor: 1,
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[icon, const SizedBox(width: 8), label],
        ),
      );
    }
    return Column(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[icon, label],
    );
  }
}

628 629
class _TileIcon extends StatelessWidget {
  const _TileIcon({
630
    required this.colorTween,
631 632 633 634 635 636
    required this.animation,
    required this.iconSize,
    required this.selected,
    required this.item,
    required this.selectedIconTheme,
    required this.unselectedIconTheme,
637
  }) : assert(selected != null),
638
       assert(item != null);
639

640
  final ColorTween colorTween;
641 642 643 644
  final Animation<double> animation;
  final double iconSize;
  final bool selected;
  final BottomNavigationBarItem item;
645 646
  final IconThemeData? selectedIconTheme;
  final IconThemeData? unselectedIconTheme;
647 648 649

  @override
  Widget build(BuildContext context) {
650
    final Color? iconColor = colorTween.evaluate(animation);
651 652 653 654 655 656 657 658 659 660
    final IconThemeData defaultIconTheme = IconThemeData(
      color: iconColor,
      size: iconSize,
    );
    final IconThemeData iconThemeData = IconThemeData.lerp(
      defaultIconTheme.merge(unselectedIconTheme),
      defaultIconTheme.merge(selectedIconTheme),
      animation.value,
    );

661
    return Align(
662 663
      alignment: Alignment.topCenter,
      heightFactor: 1.0,
664 665 666
      child: IconTheme(
        data: iconThemeData,
        child: selected ? item.activeIcon : item.icon,
667 668 669
      ),
    );
  }
670
}
671

672 673
class _Label extends StatelessWidget {
  const _Label({
674
    required this.colorTween,
675 676 677 678 679 680
    required this.animation,
    required this.item,
    required this.selectedLabelStyle,
    required this.unselectedLabelStyle,
    required this.showSelectedLabels,
    required this.showUnselectedLabels,
681
  }) : assert(colorTween != null),
682 683
       assert(animation != null),
       assert(item != null),
684 685
       assert(selectedLabelStyle != null),
       assert(unselectedLabelStyle != null),
686
       assert(showSelectedLabels != null),
687
       assert(showUnselectedLabels != null);
688

689
  final ColorTween colorTween;
690 691
  final Animation<double> animation;
  final BottomNavigationBarItem item;
692 693
  final TextStyle selectedLabelStyle;
  final TextStyle unselectedLabelStyle;
694 695
  final bool showSelectedLabels;
  final bool showUnselectedLabels;
696 697 698

  @override
  Widget build(BuildContext context) {
699 700
    final double? selectedFontSize = selectedLabelStyle.fontSize;
    final double? unselectedFontSize = unselectedLabelStyle.fontSize;
701 702 703 704 705

    final TextStyle customStyle = TextStyle.lerp(
      unselectedLabelStyle,
      selectedLabelStyle,
      animation.value,
706
    )!;
707
    Widget text = DefaultTextStyle.merge(
708
      style: customStyle.copyWith(
709
        fontSize: selectedFontSize,
710
        color: colorTween.evaluate(animation),
711 712 713 714 715 716 717 718
      ),
      // 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>(
719
              begin: unselectedFontSize! / selectedFontSize!,
720 721
              end: 1.0,
            ).evaluate(animation),
722 723
          ),
        ),
724
        alignment: Alignment.bottomCenter,
725
        child: Text(item.label!),
726 727 728
      ),
    );

729 730
    if (!showUnselectedLabels && !showSelectedLabels) {
      // Never show any labels.
731 732
      text = Visibility.maintain(
        visible: false,
733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749
        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,
      );
    }
750

751
    text = Align(
752 753
      alignment: Alignment.bottomCenter,
      heightFactor: 1.0,
754
      child: Container(child: text),
755
    );
756 757 758 759

    if (item.label != null) {
      // Do not grow text in bottom navigation bar when we can show a tooltip
      // instead.
760
      final MediaQueryData mediaQueryData = MediaQuery.of(context);
761 762 763 764 765 766 767 768 769
      text = MediaQuery(
        data: mediaQueryData.copyWith(
          textScaleFactor: math.min(1.0, mediaQueryData.textScaleFactor),
        ),
        child: text,
      );
    }

    return text;
770 771 772
  }
}

773
class _BottomNavigationBarState extends State<BottomNavigationBar> with TickerProviderStateMixin {
774
  List<AnimationController> _controllers = <AnimationController>[];
775
  late List<CurvedAnimation> _animations;
776 777

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

780 781
  // Last splash circle's color, and the final color of the control after
  // animation is complete.
782
  Color? _backgroundColor;
783

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

786
  void _resetState() {
787
    for (final AnimationController controller in _controllers) {
788
      controller.dispose();
789 790
    }
    for (final _Circle circle in _circles) {
791
      circle.dispose();
792
    }
793 794
    _circles.clear();

795 796
    _controllers = List<AnimationController>.generate(widget.items.length, (int index) {
      return AnimationController(
797 798
        duration: kThemeAnimationDuration,
        vsync: this,
799 800
      )..addListener(_rebuild);
    });
801 802
    _animations = List<CurvedAnimation>.generate(widget.items.length, (int index) {
      return CurvedAnimation(
803 804
        parent: _controllers[index],
        curve: Curves.fastOutSlowIn,
805
        reverseCurve: Curves.fastOutSlowIn.flipped,
806 807
      );
    });
808 809
    _controllers[widget.currentIndex].value = 1.0;
    _backgroundColor = widget.items[widget.currentIndex].backgroundColor;
810 811
  }

812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836
  // 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;
    }
  }

837 838 839 840 841 842
  @override
  void initState() {
    super.initState();
    _resetState();
  }

843 844
  void _rebuild() {
    setState(() {
845
      // Rebuilding when any of the controllers tick, i.e. when the items are
846 847 848 849
      // animated.
    });
  }

850 851
  @override
  void dispose() {
852
    for (final AnimationController controller in _controllers) {
853
      controller.dispose();
854 855
    }
    for (final _Circle circle in _circles) {
856
      circle.dispose();
857
    }
858
    super.dispose();
859 860
  }

861
  double _evaluateFlex(Animation<double> animation) => _flexTween.evaluate(animation);
862 863

  void _pushCircle(int index) {
864
    if (widget.items[index].backgroundColor != null) {
865
      _circles.add(
866
        _Circle(
867 868
          state: this,
          index: index,
869
          color: widget.items[index].backgroundColor!,
870
          vsync: this,
871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887
        )..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;
            }
          },
        ),
888
      );
889
    }
890 891 892
  }

  @override
893
  void didUpdateWidget(BottomNavigationBar oldWidget) {
894
    super.didUpdateWidget(oldWidget);
895 896 897 898 899 900 901

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

902
    if (widget.currentIndex != oldWidget.currentIndex) {
903
      switch (_effectiveType) {
904 905 906 907 908 909
        case BottomNavigationBarType.fixed:
          break;
        case BottomNavigationBarType.shifting:
          _pushCircle(widget.currentIndex);
          break;
      }
910 911
      _controllers[oldWidget.currentIndex].reverse();
      _controllers[widget.currentIndex].forward();
912
    } else {
913
      if (_backgroundColor != widget.items[widget.currentIndex].backgroundColor) {
914
        _backgroundColor = widget.items[widget.currentIndex].backgroundColor;
915
      }
916 917 918
    }
  }

919 920
  // If the given [TextStyle] has a non-null `fontSize`, it should be used.
  // Otherwise, the [selectedFontSize] parameter should be used.
921
  static TextStyle _effectiveTextStyle(TextStyle? textStyle, double fontSize) {
922
    textStyle ??= const TextStyle();
923 924 925 926
    // Prefer the font size on textStyle if present.
    return textStyle.fontSize == null ? textStyle.copyWith(fontSize: fontSize) : textStyle;
  }

927 928 929 930 931 932 933 934 935
  // If [IconThemeData] is provided, it should be used.
  // Otherwise, the [IconThemeData]'s color should be selectedItemColor
  // or unselectedItemColor.
  static IconThemeData _effectiveIconTheme(IconThemeData? iconTheme, Color? itemColor) {
    // Prefer the iconTheme over itemColor if present.
    return iconTheme ?? IconThemeData(color: itemColor);
  }


936
  List<Widget> _createTiles(BottomNavigationBarLandscapeLayout layout) {
937
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
938
    assert(localizations != null);
939

940
    final ThemeData themeData = Theme.of(context);
941
    final BottomNavigationBarThemeData bottomTheme = BottomNavigationBarTheme.of(context);
942

943
    final Color themeColor;
944 945
    switch (themeData.brightness) {
      case Brightness.light:
946
        themeColor = themeData.colorScheme.primary;
947 948
        break;
      case Brightness.dark:
949
        themeColor = themeData.colorScheme.secondary;
950 951 952
        break;
    }

953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985
    final TextStyle effectiveSelectedLabelStyle =
      _effectiveTextStyle(
        widget.selectedLabelStyle
        ?? bottomTheme.selectedLabelStyle,
        widget.selectedFontSize,
      );

    final TextStyle effectiveUnselectedLabelStyle =
      _effectiveTextStyle(
        widget.unselectedLabelStyle
        ?? bottomTheme.unselectedLabelStyle,
        widget.unselectedFontSize,
      );

    final IconThemeData effectiveSelectedIconTheme =
      _effectiveIconTheme(
        widget.selectedIconTheme
        ?? bottomTheme.selectedIconTheme,
        widget.selectedItemColor
        ?? bottomTheme.selectedItemColor
        ?? themeColor
      );

    final IconThemeData effectiveUnselectedIconTheme =
      _effectiveIconTheme(
        widget.unselectedIconTheme
        ?? bottomTheme.unselectedIconTheme,
        widget.unselectedItemColor
        ?? bottomTheme.unselectedItemColor
        ?? themeData.unselectedWidgetColor
      );


986
    final ColorTween colorTween;
987
    switch (_effectiveType) {
988
      case BottomNavigationBarType.fixed:
989
        colorTween = ColorTween(
990 991
          begin: widget.unselectedItemColor
            ?? bottomTheme.unselectedItemColor
992
            ?? themeData.unselectedWidgetColor,
993 994 995 996
          end: widget.selectedItemColor
            ?? bottomTheme.selectedItemColor
            ?? widget.fixedColor
            ?? themeColor,
997 998 999
        );
        break;
      case BottomNavigationBarType.shifting:
1000
        colorTween = ColorTween(
1001 1002 1003 1004 1005 1006
          begin: widget.unselectedItemColor
            ?? bottomTheme.unselectedItemColor
            ?? themeData.colorScheme.surface,
          end: widget.selectedItemColor
            ?? bottomTheme.selectedItemColor
            ?? themeData.colorScheme.surface,
1007
        );
1008 1009
        break;
    }
1010

1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068
    final ColorTween labelColorTween;
    switch (_effectiveType) {
      case BottomNavigationBarType.fixed:
        labelColorTween = ColorTween(
          begin: effectiveUnselectedLabelStyle.color
            ?? widget.unselectedItemColor
            ?? bottomTheme.unselectedItemColor
            ?? themeData.unselectedWidgetColor,
          end: effectiveSelectedLabelStyle.color
            ?? widget.selectedItemColor
            ?? bottomTheme.selectedItemColor
            ?? widget.fixedColor
            ?? themeColor,
        );
        break;
      case BottomNavigationBarType.shifting:
        labelColorTween = ColorTween(
          begin: effectiveUnselectedLabelStyle.color
            ?? widget.unselectedItemColor
            ?? bottomTheme.unselectedItemColor
            ?? themeData.colorScheme.surface,
          end: effectiveSelectedLabelStyle.color
            ?? widget.selectedItemColor
            ?? bottomTheme.selectedItemColor
            ?? themeColor,
        );
        break;
    }

    final ColorTween iconColorTween;
    switch (_effectiveType) {
      case BottomNavigationBarType.fixed:
        iconColorTween = ColorTween(
          begin: effectiveSelectedIconTheme.color
            ?? widget.unselectedItemColor
            ?? bottomTheme.unselectedItemColor
            ?? themeData.unselectedWidgetColor,
          end: effectiveUnselectedIconTheme.color
            ?? widget.selectedItemColor
            ?? bottomTheme.selectedItemColor
            ?? widget.fixedColor
            ?? themeColor,
        );
        break;
      case BottomNavigationBarType.shifting:
        iconColorTween = ColorTween(
          begin: effectiveUnselectedIconTheme.color
            ?? widget.unselectedItemColor
            ?? bottomTheme.unselectedItemColor
            ?? themeData.colorScheme.surface,
          end: effectiveSelectedIconTheme.color
            ?? widget.selectedItemColor
            ?? bottomTheme.selectedItemColor
            ?? themeColor,
        );
        break;
    }

1069 1070
    final List<Widget> tiles = <Widget>[];
    for (int i = 0; i < widget.items.length; i++) {
1071 1072 1073 1074 1075 1076 1077 1078
      final Set<MaterialState> states = <MaterialState>{
        if (i == widget.currentIndex) MaterialState.selected,
      };

      final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states)
        ?? bottomTheme.mouseCursor?.resolve(states)
        ?? MaterialStateMouseCursor.clickable.resolve(states);

1079
      tiles.add(_BottomNavigationTile(
1080
        _effectiveType,
1081 1082 1083
        widget.items[i],
        _animations[i],
        widget.iconSize,
1084 1085
        selectedIconTheme: widget.useLegacyColorScheme ? widget.selectedIconTheme ?? bottomTheme.selectedIconTheme : effectiveSelectedIconTheme,
        unselectedIconTheme: widget.useLegacyColorScheme ? widget.unselectedIconTheme ?? bottomTheme.unselectedIconTheme : effectiveUnselectedIconTheme,
1086 1087
        selectedLabelStyle: effectiveSelectedLabelStyle,
        unselectedLabelStyle: effectiveUnselectedLabelStyle,
1088
        enableFeedback: widget.enableFeedback ?? bottomTheme.enableFeedback ?? true,
1089
        onTap: () {
1090
          widget.onTap?.call(i);
1091
        },
1092 1093
        labelColorTween: widget.useLegacyColorScheme ? colorTween : labelColorTween,
        iconColorTween: widget.useLegacyColorScheme ? colorTween : iconColorTween,
1094 1095
        flex: _evaluateFlex(_animations[i]),
        selected: i == widget.currentIndex,
1096
        showSelectedLabels: widget.showSelectedLabels ?? bottomTheme.showSelectedLabels ?? true,
1097
        showUnselectedLabels: widget.showUnselectedLabels ?? bottomTheme.showUnselectedLabels ?? _defaultShowUnselected,
1098
        indexLabel: localizations.tabLabel(tabIndex: i + 1, tabCount: widget.items.length),
1099
        mouseCursor: effectiveMouseCursor,
1100
        layout: layout,
1101 1102 1103
      ));
    }
    return tiles;
1104 1105 1106 1107
  }

  @override
  Widget build(BuildContext context) {
1108
    assert(debugCheckHasDirectionality(context));
1109
    assert(debugCheckHasMaterialLocalizations(context));
1110
    assert(debugCheckHasMediaQuery(context));
1111
    assert(debugCheckHasOverlay(context));
1112

1113
    final BottomNavigationBarThemeData bottomTheme = BottomNavigationBarTheme.of(context);
1114 1115 1116
    final BottomNavigationBarLandscapeLayout layout = widget.landscapeLayout
      ?? bottomTheme.landscapeLayout
      ?? BottomNavigationBarLandscapeLayout.spread;
1117
    final double additionalBottomPadding = MediaQuery.of(context).viewPadding.bottom;
1118

1119
    Color? backgroundColor;
1120
    switch (_effectiveType) {
1121
      case BottomNavigationBarType.fixed:
1122
        backgroundColor = widget.backgroundColor ?? bottomTheme.backgroundColor;
1123 1124 1125 1126 1127
        break;
      case BottomNavigationBarType.shifting:
        backgroundColor = _backgroundColor;
        break;
    }
1128

1129
    return Semantics(
1130
      explicitChildNodes: true,
1131 1132
      child: _Bar(
        layout: layout,
1133
        elevation: widget.elevation ?? bottomTheme.elevation ?? 8.0,
1134 1135 1136 1137 1138 1139
        color: backgroundColor,
        child: ConstrainedBox(
          constraints: BoxConstraints(minHeight: kBottomNavigationBarHeight + additionalBottomPadding),
          child: CustomPaint(
            painter: _RadialPainter(
              circles: _circles.toList(),
1140
              textDirection: Directionality.of(context),
1141
            ),
1142 1143 1144 1145 1146 1147 1148
            child: Material( // Splashes.
              type: MaterialType.transparency,
              child: Padding(
                padding: EdgeInsets.only(bottom: additionalBottomPadding),
                child: MediaQuery.removePadding(
                  context: context,
                  removeBottom: true,
1149 1150 1151 1152 1153 1154 1155
                  child: DefaultTextStyle.merge(
                    overflow: TextOverflow.ellipsis,
                    child:  Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: _createTiles(layout),
                    ),
                  ),
1156
                ),
1157
              ),
1158
            ),
1159
          ),
1160
        ),
1161
      ),
1162 1163 1164 1165
    );
  }
}

1166 1167 1168 1169 1170 1171 1172 1173
// Optionally center a Material child for landscape layouts when layout is
// BottomNavigationBarLandscapeLayout.centered
class _Bar extends StatelessWidget {
  const _Bar({
    required this.child,
    required this.layout,
    required this.elevation,
    required this.color,
1174
  });
1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202

  final Widget child;
  final BottomNavigationBarLandscapeLayout layout;
  final double elevation;
  final Color? color;

  @override
  Widget build(BuildContext context) {
    final MediaQueryData data = MediaQuery.of(context);
    Widget alignedChild = child;
    if (data.orientation == Orientation.landscape && layout == BottomNavigationBarLandscapeLayout.centered) {
      alignedChild = Align(
        alignment: Alignment.bottomCenter,
        heightFactor: 1,
        child: SizedBox(
          width: data.size.height,
          child: child,
        ),
      );
    }
    return Material(
      elevation: elevation,
      color: color,
      child: alignedChild,
    );
  }
}

1203
// Describes an animating color splash circle.
1204 1205
class _Circle {
  _Circle({
1206 1207 1208 1209
    required this.state,
    required this.index,
    required this.color,
    required TickerProvider vsync,
1210 1211 1212
  }) : assert(state != null),
       assert(index != null),
       assert(color != null) {
1213
    controller = AnimationController(
1214 1215
      duration: kThemeAnimationDuration,
      vsync: vsync,
1216
    );
1217
    animation = CurvedAnimation(
1218
      parent: controller,
1219
      curve: Curves.fastOutSlowIn,
1220 1221 1222 1223
    );
    controller.forward();
  }

1224
  final _BottomNavigationBarState state;
1225 1226
  final int index;
  final Color color;
1227 1228
  late AnimationController controller;
  late CurvedAnimation animation;
1229

1230
  double get horizontalLeadingOffset {
1231 1232 1233
    double weightSum(Iterable<Animation<double>> animations) {
      // We're adding flex values instead of animation values to produce correct
      // ratios.
1234
      return animations.map<double>(state._evaluateFlex).fold<double>(0.0, (double sum, double value) => sum + value);
1235 1236 1237
    }

    final double allWeights = weightSum(state._animations);
1238 1239
    // These weights sum to the start edge of the indexed item.
    final double leadingWeights = weightSum(state._animations.sublist(0, index));
1240 1241

    // Add half of its flex value in order to get to the center.
1242
    return (leadingWeights + state._evaluateFlex(state._animations[index]) / 2.0) / allWeights;
1243 1244 1245 1246 1247 1248 1249
  }

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

1250
// Paints the animating color splash circles.
1251 1252
class _RadialPainter extends CustomPainter {
  _RadialPainter({
1253 1254
    required this.circles,
    required this.textDirection,
1255 1256
  }) : assert(circles != null),
       assert(textDirection != null);
1257 1258

  final List<_Circle> circles;
1259
  final TextDirection textDirection;
1260 1261

  // Computes the maximum radius attainable such that at least one of the
1262 1263
  // 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
1264
  // difference within the cropped rectangle.
1265 1266 1267 1268
  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);
1269 1270 1271 1272
  }

  @override
  bool shouldRepaint(_RadialPainter oldPainter) {
1273
    if (textDirection != oldPainter.textDirection) {
1274
      return true;
1275 1276
    }
    if (circles == oldPainter.circles) {
1277
      return false;
1278 1279
    }
    if (circles.length != oldPainter.circles.length) {
1280
      return true;
1281 1282 1283
    }
    for (int i = 0; i < circles.length; i += 1) {
      if (circles[i] != oldPainter.circles[i]) {
1284
        return true;
1285 1286
    }
      }
1287 1288 1289 1290 1291
    return false;
  }

  @override
  void paint(Canvas canvas, Size size) {
1292
    for (final _Circle circle in circles) {
1293 1294
      final Paint paint = Paint()..color = circle.color;
      final Rect rect = Rect.fromLTWH(0.0, 0.0, size.width, size.height);
1295
      canvas.clipRect(rect);
1296
      final double leftFraction;
1297 1298 1299 1300 1301 1302 1303 1304
      switch (textDirection) {
        case TextDirection.rtl:
          leftFraction = 1.0 - circle.horizontalLeadingOffset;
          break;
        case TextDirection.ltr:
          leftFraction = circle.horizontalLeadingOffset;
          break;
      }
1305 1306
      final Offset center = Offset(leftFraction * size.width, size.height / 2.0);
      final Tween<double> radiusTween = Tween<double>(
1307 1308
        begin: 0.0,
        end: _maxRadius(center, size),
1309
      );
1310
      canvas.drawCircle(
1311
        center,
1312
        radiusTween.transform(circle.animation.value),
1313
        paint,
1314 1315 1316 1317
      );
    }
  }
}