tabs.dart 62.2 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'dart:math' as math;
Hans Muller's avatar
Hans Muller committed
6
import 'dart:ui' show lerpDouble;
7

8
import 'package:flutter/foundation.dart';
9
import 'package:flutter/gestures.dart' show DragStartBehavior;
10 11
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
12

13
import 'app_bar.dart';
14
import 'colors.dart';
Hans Muller's avatar
Hans Muller committed
15
import 'constants.dart';
16
import 'debug.dart';
17
import 'ink_well.dart';
18
import 'material.dart';
19
import 'material_localizations.dart';
20
import 'material_state.dart';
21
import 'tab_bar_theme.dart';
Hans Muller's avatar
Hans Muller committed
22
import 'tab_controller.dart';
23
import 'tab_indicator.dart';
24
import 'theme.dart';
25 26 27

const double _kTabHeight = 46.0;
const double _kTextAndIconTabHeight = 72.0;
28 29 30 31

/// Defines how the bounds of the selected tab indicator are computed.
///
/// See also:
32
///
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
///  * [TabBar], which displays a row of tabs.
///  * [TabBarView], which displays a widget for the currently selected tab.
///  * [TabBar.indicator], which defines the appearance of the selected tab
///    indicator relative to the tab's bounds.
enum TabBarIndicatorSize {
  /// The tab indicator's bounds are as wide as the space occupied by the tab
  /// in the tab bar: from the right edge of the previous tab to the left edge
  /// of the next tab.
  tab,

  /// The tab's bounds are only as wide as the (centered) tab widget itself.
  ///
  /// This value is used to align the tab's label, typically a [Tab]
  /// widget's text or icon, with the selected tab indicator.
  label,
}
49

50
/// A Material Design [TabBar] tab.
51 52 53
///
/// If both [icon] and [text] are provided, the text is displayed below
/// the icon.
Hans Muller's avatar
Hans Muller committed
54 55 56 57 58 59
///
/// See also:
///
///  * [TabBar], which displays a row of tabs.
///  * [TabBarView], which displays a widget for the currently selected tab.
///  * [TabController], which coordinates tab selection between a [TabBar] and a [TabBarView].
60
///  * <https://material.io/design/components/tabs.html>
61
class Tab extends StatelessWidget implements PreferredSizeWidget {
62
  /// Creates a Material Design [TabBar] tab.
63 64
  ///
  /// At least one of [text], [icon], and [child] must be non-null. The [text]
65 66 67
  /// and [child] arguments must not be used at the same time. The
  /// [iconMargin] is only useful when [icon] and either one of [text] or
  /// [child] is non-null.
68
  const Tab({
69
    super.key,
Hans Muller's avatar
Hans Muller committed
70 71
    this.text,
    this.icon,
72
    this.iconMargin = const EdgeInsets.only(bottom: 10.0),
73
    this.height,
74 75
    this.child,
  }) : assert(text != null || child != null || icon != null),
76
       assert(text == null || child == null);
77

Hans Muller's avatar
Hans Muller committed
78
  /// The text to display as the tab's label.
79 80
  ///
  /// Must not be used in combination with [child].
81
  final String? text;
82

83 84 85 86 87
  /// The widget to be used as the tab's label.
  ///
  /// Usually a [Text] widget, possibly wrapped in a [Semantics] widget.
  ///
  /// Must not be used in combination with [text].
88
  final Widget? child;
89

Hans Muller's avatar
Hans Muller committed
90
  /// An icon to display as the tab's label.
91
  final Widget? icon;
92

93 94 95 96 97 98
  /// The margin added around the tab's icon.
  ///
  /// Only useful when used in combination with [icon], and either one of
  /// [text] or [child] is non-null.
  final EdgeInsetsGeometry iconMargin;

99 100
  /// The height of the [Tab].
  ///
101
  /// If null, the height will be calculated based on the content of the [Tab]. When `icon` is not
102 103 104 105
  /// null along with `child` or `text`, the default height is 72.0 pixels. Without an `icon`, the
  /// height is 46.0 pixels.
  final double? height;

106
  Widget _buildLabelText() {
107
    return child ?? Text(text!, softWrap: false, overflow: TextOverflow.fade);
108 109
  }

110
  @override
111
  Widget build(BuildContext context) {
112
    assert(debugCheckHasMaterial(context));
Hans Muller's avatar
Hans Muller committed
113

114
    final double calculatedHeight;
115
    final Widget label;
Hans Muller's avatar
Hans Muller committed
116
    if (icon == null) {
117
      calculatedHeight = _kTabHeight;
Hans Muller's avatar
Hans Muller committed
118
      label = _buildLabelText();
Ian Hickson's avatar
Ian Hickson committed
119
    } else if (text == null && child == null) {
120
      calculatedHeight = _kTabHeight;
121
      label = icon!;
122
    } else {
123
      calculatedHeight = _kTextAndIconTabHeight;
124
      label = Column(
125
        mainAxisAlignment: MainAxisAlignment.center,
126
        children: <Widget>[
127
          Container(
128
            margin: iconMargin,
129
            child: icon,
130
          ),
131 132
          _buildLabelText(),
        ],
133 134 135
      );
    }

136
    return SizedBox(
137
      height: height ?? calculatedHeight,
138
      child: Center(
139
        widthFactor: 1.0,
140
        child: label,
141
      ),
142
    );
143
  }
Hixie's avatar
Hixie committed
144

145
  @override
146 147
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
148 149
    properties.add(StringProperty('text', text, defaultValue: null));
    properties.add(DiagnosticsProperty<Widget>('icon', icon, defaultValue: null));
Hixie's avatar
Hixie committed
150
  }
151 152 153

  @override
  Size get preferredSize {
154
    if (height != null) {
155
      return Size.fromHeight(height!);
156
    } else if ((text != null || child != null) && icon != null) {
157
      return const Size.fromHeight(_kTextAndIconTabHeight);
158
    } else {
159
      return const Size.fromHeight(_kTabHeight);
160
    }
161
  }
162 163
}

Hans Muller's avatar
Hans Muller committed
164
class _TabStyle extends AnimatedWidget {
165
  const _TabStyle({
166 167 168 169 170 171 172
    required Animation<double> animation,
    required this.selected,
    required this.labelColor,
    required this.unselectedLabelColor,
    required this.labelStyle,
    required this.unselectedLabelStyle,
    required this.child,
173
  }) : super(listenable: animation);
174

175 176
  final TextStyle? labelStyle;
  final TextStyle? unselectedLabelStyle;
Hans Muller's avatar
Hans Muller committed
177
  final bool selected;
178 179
  final Color? labelColor;
  final Color? unselectedLabelColor;
Hans Muller's avatar
Hans Muller committed
180
  final Widget child;
181

182
  @override
Hans Muller's avatar
Hans Muller committed
183
  Widget build(BuildContext context) {
184
    final ThemeData themeData = Theme.of(context);
185
    final TabBarTheme tabBarTheme = TabBarTheme.of(context);
186
    final Animation<double> animation = listenable as Animation<double>;
187

188 189 190 191
    // To enable TextStyle.lerp(style1, style2, value), both styles must have
    // the same value of inherit. Force that to be inherit=true here.
    final TextStyle defaultStyle = (labelStyle
      ?? tabBarTheme.labelStyle
192
      ?? themeData.primaryTextTheme.bodyLarge!
193 194
    ).copyWith(inherit: true);
    final TextStyle defaultUnselectedStyle = (unselectedLabelStyle
195 196
      ?? tabBarTheme.unselectedLabelStyle
      ?? labelStyle
197
      ?? themeData.primaryTextTheme.bodyLarge!
198
    ).copyWith(inherit: true);
199
    final TextStyle textStyle = selected
200 201
      ? TextStyle.lerp(defaultStyle, defaultUnselectedStyle, animation.value)!
      : TextStyle.lerp(defaultUnselectedStyle, defaultStyle, animation.value)!;
202

203 204
    final Color selectedColor = labelColor
       ?? tabBarTheme.labelColor
205
       ?? themeData.primaryTextTheme.bodyLarge!.color!;
206 207 208
    final Color unselectedColor = unselectedLabelColor
      ?? tabBarTheme.unselectedLabelColor
      ?? selectedColor.withAlpha(0xB2); // 70% alpha
Hans Muller's avatar
Hans Muller committed
209
    final Color color = selected
210 211
      ? Color.lerp(selectedColor, unselectedColor, animation.value)!
      : Color.lerp(unselectedColor, selectedColor, animation.value)!;
Hans Muller's avatar
Hans Muller committed
212

213
    return DefaultTextStyle(
Hans Muller's avatar
Hans Muller committed
214
      style: textStyle.copyWith(color: color),
215
      child: IconTheme.merge(
216
        data: IconThemeData(
Hans Muller's avatar
Hans Muller committed
217 218 219 220 221
          size: 24.0,
          color: color,
        ),
        child: child,
      ),
222 223 224 225
    );
  }
}

226
typedef _LayoutCallback = void Function(List<double> xOffsets, TextDirection textDirection, double width);
Ian Hickson's avatar
Ian Hickson committed
227

Hans Muller's avatar
Hans Muller committed
228 229
class _TabLabelBarRenderer extends RenderFlex {
  _TabLabelBarRenderer({
230 231 232 233 234 235
    required super.direction,
    required super.mainAxisSize,
    required super.mainAxisAlignment,
    required super.crossAxisAlignment,
    required TextDirection super.textDirection,
    required super.verticalDirection,
236
    required this.onPerformLayout,
237
  }) : assert(onPerformLayout != null),
238
       assert(textDirection != null);
239

Ian Hickson's avatar
Ian Hickson committed
240
  _LayoutCallback onPerformLayout;
241 242

  @override
Hans Muller's avatar
Hans Muller committed
243 244
  void performLayout() {
    super.performLayout();
Ian Hickson's avatar
Ian Hickson committed
245 246 247 248
    // xOffsets will contain childCount+1 values, giving the offsets of the
    // leading edge of the first tab as the first value, of the leading edge of
    // the each subsequent tab as each subsequent value, and of the trailing
    // edge of the last tab as the last value.
249
    RenderBox? child = firstChild;
Hans Muller's avatar
Hans Muller committed
250 251
    final List<double> xOffsets = <double>[];
    while (child != null) {
252
      final FlexParentData childParentData = child.parentData! as FlexParentData;
Hans Muller's avatar
Hans Muller committed
253 254 255 256
      xOffsets.add(childParentData.offset.dx);
      assert(child.parentData == childParentData);
      child = childParentData.nextSibling;
    }
Ian Hickson's avatar
Ian Hickson committed
257
    assert(textDirection != null);
258
    switch (textDirection!) {
Ian Hickson's avatar
Ian Hickson committed
259 260 261 262 263 264 265
      case TextDirection.rtl:
        xOffsets.insert(0, size.width);
        break;
      case TextDirection.ltr:
        xOffsets.add(size.width);
        break;
    }
266
    onPerformLayout(xOffsets, textDirection!, size.width);
267
  }
Hans Muller's avatar
Hans Muller committed
268 269
}

Hans Muller's avatar
Hans Muller committed
270 271 272 273 274
// This class and its renderer class only exist to report the widths of the tabs
// upon layout. The tab widths are only used at paint time (see _IndicatorPainter)
// or in response to input.
class _TabLabelBar extends Flex {
  _TabLabelBar({
275
    super.children,
276
    required this.onPerformLayout,
Hans Muller's avatar
Hans Muller committed
277 278 279 280 281
  }) : super(
    direction: Axis.horizontal,
    mainAxisSize: MainAxisSize.max,
    mainAxisAlignment: MainAxisAlignment.start,
    crossAxisAlignment: CrossAxisAlignment.center,
Ian Hickson's avatar
Ian Hickson committed
282
    verticalDirection: VerticalDirection.down,
Hans Muller's avatar
Hans Muller committed
283 284
  );

Ian Hickson's avatar
Ian Hickson committed
285
  final _LayoutCallback onPerformLayout;
Hans Muller's avatar
Hans Muller committed
286 287 288

  @override
  RenderFlex createRenderObject(BuildContext context) {
289
    return _TabLabelBarRenderer(
Hans Muller's avatar
Hans Muller committed
290 291 292 293
      direction: direction,
      mainAxisAlignment: mainAxisAlignment,
      mainAxisSize: mainAxisSize,
      crossAxisAlignment: crossAxisAlignment,
294
      textDirection: getEffectiveTextDirection(context)!,
295
      verticalDirection: verticalDirection,
Hans Muller's avatar
Hans Muller committed
296
      onPerformLayout: onPerformLayout,
297
    );
298 299
  }

300
  @override
Hans Muller's avatar
Hans Muller committed
301 302 303
  void updateRenderObject(BuildContext context, _TabLabelBarRenderer renderObject) {
    super.updateRenderObject(context, renderObject);
    renderObject.onPerformLayout = onPerformLayout;
Hans Muller's avatar
Hans Muller committed
304
  }
Hans Muller's avatar
Hans Muller committed
305
}
Hans Muller's avatar
Hans Muller committed
306

Hans Muller's avatar
Hans Muller committed
307
double _indexChangeProgress(TabController controller) {
308
  final double controllerValue = controller.animation!.value;
Hans Muller's avatar
Hans Muller committed
309 310
  final double previousIndex = controller.previousIndex.toDouble();
  final double currentIndex = controller.index.toDouble();
311 312 313

  // The controller's offset is changing because the user is dragging the
  // TabBarView's PageView to the left or right.
314
  if (!controller.indexIsChanging) {
315
    return clampDouble((currentIndex - controllerValue).abs(), 0.0, 1.0);
316
  }
317 318 319

  // The TabController animation's value is changing from previousIndex to currentIndex.
  return (controllerValue - currentIndex).abs() / (currentIndex - previousIndex).abs();
Hans Muller's avatar
Hans Muller committed
320
}
321

Hans Muller's avatar
Hans Muller committed
322
class _IndicatorPainter extends CustomPainter {
323
  _IndicatorPainter({
324 325 326 327 328
    required this.controller,
    required this.indicator,
    required this.indicatorSize,
    required this.tabKeys,
    required _IndicatorPainter? old,
329
    required this.indicatorPadding,
Ian Hickson's avatar
Ian Hickson committed
330
  }) : assert(controller != null),
331
       assert(indicator != null),
Ian Hickson's avatar
Ian Hickson committed
332
       super(repaint: controller.animation) {
333
    if (old != null) {
Ian Hickson's avatar
Ian Hickson committed
334
      saveTabOffsets(old._currentTabOffsets, old._currentTextDirection);
335
    }
Ian Hickson's avatar
Ian Hickson committed
336
  }
337

338
  final TabController controller;
339
  final Decoration indicator;
340
  final TabBarIndicatorSize? indicatorSize;
341
  final EdgeInsetsGeometry indicatorPadding;
342
  final List<GlobalKey> tabKeys;
Ian Hickson's avatar
Ian Hickson committed
343

344 345 346 347 348 349
  // _currentTabOffsets and _currentTextDirection are set each time TabBar
  // layout is completed. These values can be null when TabBar contains no
  // tabs, since there are nothing to lay out.
  List<double>? _currentTabOffsets;
  TextDirection? _currentTextDirection;

350 351
  Rect? _currentRect;
  BoxPainter? _painter;
352 353 354 355 356 357 358 359
  bool _needsPaint = false;
  void markNeedsPaint() {
    _needsPaint = true;
  }

  void dispose() {
    _painter?.dispose();
  }
360

361
  void saveTabOffsets(List<double>? tabOffsets, TextDirection? textDirection) {
Ian Hickson's avatar
Ian Hickson committed
362 363 364 365 366 367
    _currentTabOffsets = tabOffsets;
    _currentTextDirection = textDirection;
  }

  // _currentTabOffsets[index] is the offset of the start edge of the tab at index, and
  // _currentTabOffsets[_currentTabOffsets.length] is the end edge of the last tab.
368
  int get maxTabIndex => _currentTabOffsets!.length - 2;
Ian Hickson's avatar
Ian Hickson committed
369 370 371

  double centerOf(int tabIndex) {
    assert(_currentTabOffsets != null);
372
    assert(_currentTabOffsets!.isNotEmpty);
Ian Hickson's avatar
Ian Hickson committed
373 374
    assert(tabIndex >= 0);
    assert(tabIndex <= maxTabIndex);
375
    return (_currentTabOffsets![tabIndex] + _currentTabOffsets![tabIndex + 1]) / 2.0;
Ian Hickson's avatar
Ian Hickson committed
376
  }
377

Hans Muller's avatar
Hans Muller committed
378
  Rect indicatorRect(Size tabBarSize, int tabIndex) {
Ian Hickson's avatar
Ian Hickson committed
379 380
    assert(_currentTabOffsets != null);
    assert(_currentTextDirection != null);
381
    assert(_currentTabOffsets!.isNotEmpty);
Ian Hickson's avatar
Ian Hickson committed
382 383 384
    assert(tabIndex >= 0);
    assert(tabIndex <= maxTabIndex);
    double tabLeft, tabRight;
385
    switch (_currentTextDirection!) {
Ian Hickson's avatar
Ian Hickson committed
386
      case TextDirection.rtl:
387 388
        tabLeft = _currentTabOffsets![tabIndex + 1];
        tabRight = _currentTabOffsets![tabIndex];
Ian Hickson's avatar
Ian Hickson committed
389 390
        break;
      case TextDirection.ltr:
391 392
        tabLeft = _currentTabOffsets![tabIndex];
        tabRight = _currentTabOffsets![tabIndex + 1];
Ian Hickson's avatar
Ian Hickson committed
393 394
        break;
    }
395 396

    if (indicatorSize == TabBarIndicatorSize.label) {
397
      final double tabWidth = tabKeys[tabIndex].currentContext!.size!.width;
398 399 400 401 402
      final double delta = ((tabRight - tabLeft) - tabWidth) / 2.0;
      tabLeft += delta;
      tabRight -= delta;
    }

403 404 405 406 407 408
    final EdgeInsets insets = indicatorPadding.resolve(_currentTextDirection);
    final Rect rect = Rect.fromLTWH(tabLeft, 0.0, tabRight - tabLeft, tabBarSize.height);

    if (!(rect.size >= insets.collapsedSize)) {
      throw FlutterError(
          'indicatorPadding insets should be less than Tab Size\n'
409
          'Rect Size : ${rect.size}, Insets: $insets',
410 411 412
      );
    }
    return insets.deflateRect(rect);
413 414
  }

Hans Muller's avatar
Hans Muller committed
415 416
  @override
  void paint(Canvas canvas, Size size) {
417 418 419
    _needsPaint = false;
    _painter ??= indicator.createBoxPainter(markNeedsPaint);

420 421 422
    final double index = controller.index.toDouble();
    final double value = controller.animation!.value;
    final bool ltr = index > value;
423 424
    final int from = (ltr ? value.floor() : value.ceil()).clamp(0, maxTabIndex); // ignore_clamp_double_lint
    final int to = (ltr ? from + 1 : from - 1).clamp(0, maxTabIndex); // ignore_clamp_double_lint
425 426 427
    final Rect fromRect = indicatorRect(size, from);
    final Rect toRect = indicatorRect(size, to);
    _currentRect = Rect.lerp(fromRect, toRect, (value - from).abs());
428
    assert(_currentRect != null);
429

430
    final ImageConfiguration configuration = ImageConfiguration(
431
      size: _currentRect!.size,
432 433
      textDirection: _currentTextDirection,
    );
434
    _painter!.paint(canvas, _currentRect!.topLeft, configuration);
435 436
  }

437
  @override
Hans Muller's avatar
Hans Muller committed
438
  bool shouldRepaint(_IndicatorPainter old) {
439 440 441 442
    return _needsPaint
        || controller != old.controller
        || indicator != old.indicator
        || tabKeys.length != old.tabKeys.length
443
        || (!listEquals(_currentTabOffsets, old._currentTabOffsets))
Ian Hickson's avatar
Ian Hickson committed
444
        || _currentTextDirection != old._currentTextDirection;
Hans Muller's avatar
Hans Muller committed
445
  }
446 447
}

Hans Muller's avatar
Hans Muller committed
448 449
class _ChangeAnimation extends Animation<double> with AnimationWithParentMixin<double> {
  _ChangeAnimation(this.controller);
450

Hans Muller's avatar
Hans Muller committed
451
  final TabController controller;
452 453

  @override
454
  Animation<double> get parent => controller.animation!;
Hans Muller's avatar
Hans Muller committed
455

456 457
  @override
  void removeStatusListener(AnimationStatusListener listener) {
458
    if (controller.animation != null) {
459
      super.removeStatusListener(listener);
460
    }
461 462 463 464
  }

  @override
  void removeListener(VoidCallback listener) {
465
    if (controller.animation != null) {
466
      super.removeListener(listener);
467
    }
468 469
  }

Hans Muller's avatar
Hans Muller committed
470 471
  @override
  double get value => _indexChangeProgress(controller);
472 473
}

474 475 476 477 478 479 480
class _DragAnimation extends Animation<double> with AnimationWithParentMixin<double> {
  _DragAnimation(this.controller, this.index);

  final TabController controller;
  final int index;

  @override
481
  Animation<double> get parent => controller.animation!;
482

483 484
  @override
  void removeStatusListener(AnimationStatusListener listener) {
485
    if (controller.animation != null) {
486
      super.removeStatusListener(listener);
487
    }
488 489 490 491
  }

  @override
  void removeListener(VoidCallback listener) {
492
    if (controller.animation != null) {
493
      super.removeListener(listener);
494
    }
495 496
  }

497 498 499
  @override
  double get value {
    assert(!controller.indexIsChanging);
500
    final double controllerMaxValue = (controller.length - 1).toDouble();
501 502
    final double controllerValue = clampDouble(controller.animation!.value, 0.0, controllerMaxValue);
    return clampDouble((controllerValue - index.toDouble()).abs(), 0.0, 1.0);
503 504 505
  }
}

506
// This class, and TabBarScrollController, only exist to handle the case
507 508 509
// where a scrollable TabBar has a non-zero initialIndex. In that case we can
// only compute the scroll position's initial scroll offset (the "correct"
// pixels value) after the TabBar viewport width and scroll limits are known.
510
class _TabBarScrollPosition extends ScrollPositionWithSingleContext {
511
  _TabBarScrollPosition({
512 513 514
    required super.physics,
    required super.context,
    required super.oldPosition,
515
    required this.tabBar,
516 517 518 519 520 521
  }) : super(
    initialPixels: null,
  );

  final _TabBarState tabBar;

522
  bool? _initialViewportDimensionWasZero;
523

524 525 526
  @override
  bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
    bool result = true;
527 528 529 530 531 532 533 534 535 536
    if (_initialViewportDimensionWasZero != true) {
      // If the viewport never had a non-zero dimension, we just want to jump
      // to the initial scroll position to avoid strange scrolling effects in
      // release mode: In release mode, the viewport temporarily may have a
      // dimension of zero before the actual dimension is calculated. In that
      // scenario, setting the actual dimension would cause a strange scroll
      // effect without this guard because the super call below would starts a
      // ballistic scroll activity.
      assert(viewportDimension != null);
      _initialViewportDimensionWasZero = viewportDimension != 0.0;
537 538 539 540 541 542 543
      correctPixels(tabBar._initialScrollOffset(viewportDimension, minScrollExtent, maxScrollExtent));
      result = false;
    }
    return super.applyContentDimensions(minScrollExtent, maxScrollExtent) && result;
  }
}

544
// This class, and TabBarScrollPosition, only exist to handle the case
545 546 547 548 549 550 551
// where a scrollable TabBar has a non-zero initialIndex.
class _TabBarScrollController extends ScrollController {
  _TabBarScrollController(this.tabBar);

  final _TabBarState tabBar;

  @override
552
  ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
553
    return _TabBarScrollPosition(
554
      physics: physics,
555
      context: context,
556 557 558 559 560 561
      oldPosition: oldPosition,
      tabBar: tabBar,
    );
  }
}

562
/// A Material Design widget that displays a horizontal row of tabs.
563
///
564
/// Typically created as the [AppBar.bottom] part of an [AppBar] and in
565
/// conjunction with a [TabBarView].
566
///
567 568
/// {@youtube 560 315 https://www.youtube.com/watch?v=POtoEH-5l40}
///
569 570
/// If a [TabController] is not provided, then a [DefaultTabController] ancestor
/// must be provided instead. The tab controller's [TabController.length] must
571 572
/// equal the length of the [tabs] list and the length of the
/// [TabBarView.children] list.
573 574 575
///
/// Requires one of its ancestors to be a [Material] widget.
///
576
/// Uses values from [TabBarTheme] if it is set in the current context.
577
///
578
/// {@tool dartpad}
579 580 581
/// This sample shows the implementation of [TabBar] and [TabBarView] using a [DefaultTabController].
/// Each [Tab] corresponds to a child of the [TabBarView] in the order they are written.
///
582
/// ** See code in examples/api/lib/material/tabs/tab_bar.0.dart **
583 584
/// {@end-tool}
///
585
/// {@tool dartpad}
586
/// [TabBar] can also be implemented by using a [TabController] which provides more options
587 588 589
/// to control the behavior of the [TabBar] and [TabBarView]. This can be used instead of
/// a [DefaultTabController], demonstrated below.
///
590
/// ** See code in examples/api/lib/material/tabs/tab_bar.1.dart **
591
/// {@end-tool}
592
///
593 594
/// See also:
///
595
///  * [TabBarView], which displays page views that correspond to each tab.
596
///  * [TabBar], which is used to display the [Tab] that corresponds to each page of the [TabBarView].
597
class TabBar extends StatefulWidget implements PreferredSizeWidget {
598
  /// Creates a Material Design tab bar.
599
  ///
600
  /// The [tabs] argument must not be null and its length must match the [controller]'s
601
  /// [TabController.length].
602 603 604
  ///
  /// If a [TabController] is not provided, then there must be a
  /// [DefaultTabController] ancestor.
605
  ///
606
  /// The [indicatorWeight] parameter defaults to 2, and must not be null.
607
  ///
608
  /// The [indicatorPadding] parameter defaults to [EdgeInsets.zero], and must not be null.
609
  ///
610 611
  /// If [indicator] is not null or provided from [TabBarTheme],
  /// then [indicatorWeight], [indicatorPadding], and [indicatorColor] are ignored.
612
  const TabBar({
613
    super.key,
614
    required this.tabs,
Hans Muller's avatar
Hans Muller committed
615
    this.controller,
616
    this.isScrollable = false,
617
    this.padding,
618
    this.indicatorColor,
619
    this.automaticIndicatorColorAdjustment = true,
620 621
    this.indicatorWeight = 2.0,
    this.indicatorPadding = EdgeInsets.zero,
622 623
    this.indicator,
    this.indicatorSize,
Hans Muller's avatar
Hans Muller committed
624
    this.labelColor,
625
    this.labelStyle,
626
    this.labelPadding,
627
    this.unselectedLabelColor,
628
    this.unselectedLabelStyle,
629
    this.dragStartBehavior = DragStartBehavior.start,
630
    this.overlayColor,
631
    this.mouseCursor,
632
    this.enableFeedback,
633
    this.onTap,
634
    this.physics,
635
    this.splashFactory,
636
    this.splashBorderRadius,
637
  }) : assert(tabs != null),
638
       assert(isScrollable != null),
639
       assert(dragStartBehavior != null),
640
       assert(indicator != null || (indicatorWeight != null && indicatorWeight > 0.0)),
641
       assert(indicator != null || (indicatorPadding != null));
642

643 644
  /// Typically a list of two or more [Tab] widgets.
  ///
645 646
  /// The length of this list must match the [controller]'s [TabController.length]
  /// and the length of the [TabBarView.children] list.
Hans Muller's avatar
Hans Muller committed
647 648 649
  final List<Widget> tabs;

  /// This widget's selection and animation state.
650
  ///
Hans Muller's avatar
Hans Muller committed
651 652
  /// If [TabController] is not provided, then the value of [DefaultTabController.of]
  /// will be used.
653
  final TabController? controller;
654 655 656

  /// Whether this tab bar can be scrolled horizontally.
  ///
657
  /// If [isScrollable] is true, then each tab is as wide as needed for its label
658 659
  /// and the entire [TabBar] is scrollable. Otherwise each tab gets an equal
  /// share of the available space.
660
  final bool isScrollable;
661

662 663 664 665 666 667 668
  /// The amount of space by which to inset the tab bar.
  ///
  /// When [isScrollable] is false, this will yield the same result as if you had wrapped your
  /// [TabBar] in a [Padding] widget. When [isScrollable] is true, the scrollable itself is inset,
  /// allowing the padding to scroll with the tab bar, rather than enclosing it.
  final EdgeInsetsGeometry? padding;

669 670 671 672
  /// The color of the line that appears below the selected tab.
  ///
  /// If this parameter is null, then the value of the Theme's indicatorColor
  /// property is used.
673
  ///
674 675
  /// If [indicator] is specified or provided from [TabBarTheme],
  /// this property is ignored.
676
  final Color? indicatorColor;
677

678
  /// The thickness of the line that appears below the selected tab.
679
  ///
680 681
  /// The value of this parameter must be greater than zero and its default
  /// value is 2.0.
682
  ///
683 684
  /// If [indicator] is specified or provided from [TabBarTheme],
  /// this property is ignored.
685 686
  final double indicatorWeight;

687 688 689 690

  /// Padding for indicator.
  /// This property will now no longer be ignored even if indicator is declared
  /// or provided by [TabBarTheme]
691
  ///
692
  /// For [isScrollable] tab bars, specifying [kTabLabelPadding] will align
693 694 695 696
  /// the indicator with the tab's text for [Tab] widgets and all but the
  /// shortest [Tab.text] values.
  ///
  /// The default value of [indicatorPadding] is [EdgeInsets.zero].
Ian Hickson's avatar
Ian Hickson committed
697
  final EdgeInsetsGeometry indicatorPadding;
698

699 700
  /// Defines the appearance of the selected tab indicator.
  ///
701
  /// If [indicator] is specified or provided from [TabBarTheme],
702
  /// the [indicatorColor], and [indicatorWeight] properties are ignored.
703 704 705 706 707 708 709
  ///
  /// The default, underline-style, selected tab indicator can be defined with
  /// [UnderlineTabIndicator].
  ///
  /// The indicator's size is based on the tab's bounds. If [indicatorSize]
  /// is [TabBarIndicatorSize.tab] the tab's bounds are as wide as the space
  /// occupied by the tab in the tab bar. If [indicatorSize] is
710
  /// [TabBarIndicatorSize.label], then the tab's bounds are only as wide as
711
  /// the tab widget itself.
712 713 714 715 716
  ///
  /// See also:
  ///
  ///  * [splashBorderRadius], which defines the clipping radius of the splash
  ///    and is generally used with [BoxDecoration.borderRadius].
717
  final Decoration? indicator;
718

719 720 721 722 723 724 725
  /// Whether this tab bar should automatically adjust the [indicatorColor].
  ///
  /// If [automaticIndicatorColorAdjustment] is true,
  /// then the [indicatorColor] will be automatically adjusted to [Colors.white]
  /// when the [indicatorColor] is same as [Material.color] of the [Material] parent widget.
  final bool automaticIndicatorColorAdjustment;

726 727 728 729 730 731 732 733 734 735
  /// Defines how the selected tab indicator's size is computed.
  ///
  /// The size of the selected tab indicator is defined relative to the
  /// tab's overall bounds if [indicatorSize] is [TabBarIndicatorSize.tab]
  /// (the default) or relative to the bounds of the tab's widget if
  /// [indicatorSize] is [TabBarIndicatorSize.label].
  ///
  /// The selected tab's location appearance can be refined further with
  /// the [indicatorColor], [indicatorWeight], [indicatorPadding], and
  /// [indicator] properties.
736
  final TabBarIndicatorSize? indicatorSize;
737

738 739 740 741 742
  /// The color of selected tab labels.
  ///
  /// Unselected tab labels are rendered with the same color rendered at 70%
  /// opacity unless [unselectedLabelColor] is non-null.
  ///
743
  /// If this parameter is null, then the color of the [ThemeData.primaryTextTheme]'s
744
  /// [TextTheme.bodyLarge] text color is used.
745
  final Color? labelColor;
746

747 748
  /// The color of unselected tab labels.
  ///
749 750
  /// If this property is null, unselected tab labels are rendered with the
  /// [labelColor] with 70% opacity.
751
  final Color? unselectedLabelColor;
752

753
  /// The text style of the selected tab labels.
754
  ///
755 756 757 758
  /// If [unselectedLabelStyle] is null, then this text style will be used for
  /// both selected and unselected label styles.
  ///
  /// If this property is null, then the text style of the
759
  /// [ThemeData.primaryTextTheme]'s [TextTheme.bodyLarge] definition is used.
760
  final TextStyle? labelStyle;
761

762 763
  /// The padding added to each of the tab labels.
  ///
764 765 766 767
  /// If there are few tabs with both icon and text and few
  /// tabs with only icon or text, this padding is vertically
  /// adjusted to provide uniform padding to all tabs.
  ///
768
  /// If this property is null, then kTabLabelPadding is used.
769
  final EdgeInsetsGeometry? labelPadding;
770

771
  /// The text style of the unselected tab labels.
772
  ///
773 774
  /// If this property is null, then the [labelStyle] value is used. If [labelStyle]
  /// is null, then the text style of the [ThemeData.primaryTextTheme]'s
775
  /// [TextTheme.bodyLarge] definition is used.
776
  final TextStyle? unselectedLabelStyle;
777

778 779 780 781 782 783
  /// Defines the ink response focus, hover, and splash colors.
  ///
  /// If non-null, it is resolved against one of [MaterialState.focused],
  /// [MaterialState.hovered], and [MaterialState.pressed].
  ///
  /// [MaterialState.pressed] triggers a ripple (an ink splash), per
784
  /// the current Material Design spec.
785 786
  ///
  /// If the overlay color is null or resolves to null, then the default values
787 788
  /// for [InkResponse.focusColor], [InkResponse.hoverColor], [InkResponse.splashColor],
  /// and [InkResponse.highlightColor] will be used instead.
789 790
  final MaterialStateProperty<Color?>? overlayColor;

791 792 793
  /// {@macro flutter.widgets.scrollable.dragStartBehavior}
  final DragStartBehavior dragStartBehavior;

794
  /// {@template flutter.material.tabs.mouseCursor}
795 796 797
  /// The cursor for a mouse pointer when it enters or is hovering over the
  /// individual tab widgets.
  ///
798 799 800 801 802 803 804 805 806 807 808 809 810
  /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>],
  /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s:
  ///
  ///  * [MaterialState.selected].
  /// {@endtemplate}
  ///
  /// If null, then the value of [TabBarTheme.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>].
811
  final MouseCursor? mouseCursor;
812

813 814 815 816 817 818 819 820
  /// 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.
  ///
  /// Defaults to true.
  final bool? enableFeedback;

821 822 823 824 825 826 827 828 829
  /// An optional callback that's called when the [TabBar] is tapped.
  ///
  /// The callback is applied to the index of the tab where the tap occurred.
  ///
  /// This callback has no effect on the default handling of taps. It's for
  /// applications that want to do a little extra work when a tab is tapped,
  /// even if the tap doesn't change the TabController's index. TabBar [onTap]
  /// callbacks should not make changes to the TabController since that would
  /// interfere with the default tap handler.
830
  final ValueChanged<int>? onTap;
831

832 833 834 835 836 837
  /// How the [TabBar]'s scroll view should respond to user input.
  ///
  /// For example, determines how the scroll view continues to animate after the
  /// user stops dragging the scroll view.
  ///
  /// Defaults to matching platform conventions.
838
  final ScrollPhysics? physics;
839

840 841 842 843 844 845
  /// Creates the tab bar's [InkWell] splash factory, which defines
  /// the appearance of "ink" splashes that occur in response to taps.
  ///
  /// Use [NoSplash.splashFactory] to defeat ink splash rendering. For example
  /// to defeat both the splash and the hover/pressed overlay, but not the
  /// keyboard focused overlay:
846
  ///
847 848 849 850 851 852 853 854
  /// ```dart
  /// TabBar(
  ///   splashFactory: NoSplash.splashFactory,
  ///   overlayColor: MaterialStateProperty.resolveWith<Color?>(
  ///     (Set<MaterialState> states) {
  ///       return states.contains(MaterialState.focused) ? null : Colors.transparent;
  ///     },
  ///   ),
855 856 857
  ///   tabs: const <Widget>[
  ///     // ...
  ///   ],
858 859 860 861
  /// )
  /// ```
  final InteractiveInkFeatureFactory? splashFactory;

862 863 864
  /// Defines the clipping radius of splashes that extend outside the bounds of the tab.
  ///
  /// This can be useful to match the [BoxDecoration.borderRadius] provided as [indicator].
865
  ///
866 867 868 869 870 871
  /// ```dart
  /// TabBar(
  ///   indicator: BoxDecoration(
  ///     borderRadius: BorderRadius.circular(40),
  ///   ),
  ///   splashBorderRadius: BorderRadius.circular(40),
872 873 874
  ///   tabs: const <Widget>[
  ///     // ...
  ///   ],
875 876 877 878 879 880
  /// )
  /// ```
  ///
  /// If this property is null, it is interpreted as [BorderRadius.zero].
  final BorderRadius? splashBorderRadius;

881 882
  /// A size whose height depends on if the tabs have both icons and text.
  ///
883
  /// [AppBar] uses this size to compute its own preferred size.
884
  @override
885
  Size get preferredSize {
886
    double maxHeight = _kTabHeight;
887
    for (final Widget item in tabs) {
888 889 890
      if (item is PreferredSizeWidget) {
        final double itemHeight = item.preferredSize.height;
        maxHeight = math.max(itemHeight, maxHeight);
Hans Muller's avatar
Hans Muller committed
891
      }
892
    }
893
    return Size.fromHeight(maxHeight + indicatorWeight);
894 895
  }

896 897 898 899 900 901 902 903
  /// Returns whether the [TabBar] contains a tab with both text and icon.
  ///
  /// [TabBar] uses this to give uniform padding to all tabs in cases where
  /// there are some tabs with both text and icon and some which contain only
  /// text or icon.
  bool get tabHasTextAndIcon {
    for (final Widget item in tabs) {
      if (item is PreferredSizeWidget) {
904
        if (item.preferredSize.height == _kTextAndIconTabHeight) {
905
          return true;
906
        }
907 908 909 910 911
      }
    }
    return false;
  }

912
  @override
913
  State<TabBar> createState() => _TabBarState();
914
}
915

Hans Muller's avatar
Hans Muller committed
916
class _TabBarState extends State<TabBar> {
917 918 919 920 921 922
  ScrollController? _scrollController;
  TabController? _controller;
  _IndicatorPainter? _indicatorPainter;
  int? _currentIndex;
  late double _tabStripWidth;
  late List<GlobalKey> _tabKeys;
923
  bool _debugHasScheduledValidTabsCountCheck = false;
924 925 926 927 928 929

  @override
  void initState() {
    super.initState();
    // If indicatorSize is TabIndicatorSize.label, _tabKeys[i] is used to find
    // the width of tab widget i. See _IndicatorPainter.indicatorRect().
930
    _tabKeys = widget.tabs.map((Widget tab) => GlobalKey()).toList();
931 932 933
  }

  Decoration get _indicator {
934
    if (widget.indicator != null) {
935
      return widget.indicator!;
936
    }
937
    final TabBarTheme tabBarTheme = TabBarTheme.of(context);
938
    if (tabBarTheme.indicator != null) {
939
      return tabBarTheme.indicator!;
940
    }
941

942
    Color color = widget.indicatorColor ?? Theme.of(context).indicatorColor;
943 944 945 946 947 948 949
    // ThemeData tries to avoid this by having indicatorColor avoid being the
    // primaryColor. However, it's possible that the tab bar is on a
    // Material that isn't the primaryColor. In that case, if the indicator
    // color ends up matching the material's color, then this overrides it.
    // When that happens, automatic transitions of the theme will likely look
    // ugly as the indicator color suddenly snaps to white at one end, but it's
    // not clear how to avoid that any further.
Ian Hickson's avatar
Ian Hickson committed
950 951 952
    //
    // The material's color might be null (if it's a transparency). In that case
    // there's no good way for us to find out what the color is so we don't.
953 954 955 956
    //
    // TODO(xu-baolin): Remove automatic adjustment to white color indicator
    // with a better long-term solution.
    // https://github.com/flutter/flutter/pull/68171#pullrequestreview-517753917
957
    if (widget.automaticIndicatorColorAdjustment && color.value == Material.maybeOf(context)?.color?.value) {
958
      color = Colors.white;
959
    }
960

961 962
    return UnderlineTabIndicator(
      borderSide: BorderSide(
963 964 965 966 967
        width: widget.indicatorWeight,
        color: color,
      ),
    );
  }
Hans Muller's avatar
Hans Muller committed
968

969 970 971 972 973
  // If the TabBar is rebuilt with a new tab controller, the caller should
  // dispose the old one. In that case the old controller's animation will be
  // null and should not be accessed.
  bool get _controllerIsValid => _controller?.animation != null;

Hans Muller's avatar
Hans Muller committed
974
  void _updateTabController() {
975
    final TabController? newController = widget.controller ?? DefaultTabController.maybeOf(context);
976 977
    assert(() {
      if (newController == null) {
978
        throw FlutterError(
979 980
          'No TabController for ${widget.runtimeType}.\n'
          'When creating a ${widget.runtimeType}, you must either provide an explicit '
981
          'TabController using the "controller" property, or you must ensure that there '
982
          'is a DefaultTabController above the ${widget.runtimeType}.\n'
983
          'In this case, there was neither an explicit controller nor a default controller.',
984 985 986
        );
      }
      return true;
987
    }());
988

989
    if (newController == _controller) {
990
      return;
991
    }
Hans Muller's avatar
Hans Muller committed
992

993
    if (_controllerIsValid) {
994 995
      _controller!.animation!.removeListener(_handleTabControllerAnimationTick);
      _controller!.removeListener(_handleTabControllerTick);
996
    }
Hans Muller's avatar
Hans Muller committed
997 998
    _controller = newController;
    if (_controller != null) {
999 1000 1001
      _controller!.animation!.addListener(_handleTabControllerAnimationTick);
      _controller!.addListener(_handleTabControllerTick);
      _currentIndex = _controller!.index;
1002
    }
1003 1004
  }

1005
  void _initIndicatorPainter() {
1006
    _indicatorPainter = !_controllerIsValid ? null : _IndicatorPainter(
1007
      controller: _controller!,
1008
      indicator: _indicator,
1009
      indicatorSize: widget.indicatorSize ?? TabBarTheme.of(context).indicatorSize,
1010
      indicatorPadding: widget.indicatorPadding,
1011 1012 1013 1014 1015
      tabKeys: _tabKeys,
      old: _indicatorPainter,
    );
  }

1016
  @override
1017 1018
  void didChangeDependencies() {
    super.didChangeDependencies();
Ian Hickson's avatar
Ian Hickson committed
1019
    assert(debugCheckHasMaterial(context));
Hans Muller's avatar
Hans Muller committed
1020
    _updateTabController();
1021
    _initIndicatorPainter();
1022 1023
  }

1024
  @override
1025 1026
  void didUpdateWidget(TabBar oldWidget) {
    super.didUpdateWidget(oldWidget);
1027
    if (widget.controller != oldWidget.controller) {
Hans Muller's avatar
Hans Muller committed
1028
      _updateTabController();
1029 1030
      _initIndicatorPainter();
    } else if (widget.indicatorColor != oldWidget.indicatorColor ||
1031 1032
        widget.indicatorWeight != oldWidget.indicatorWeight ||
        widget.indicatorSize != oldWidget.indicatorSize ||
1033
        widget.indicatorPadding != oldWidget.indicatorPadding ||
1034
        widget.indicator != oldWidget.indicator) {
1035
      _initIndicatorPainter();
1036
    }
1037

1038 1039
    if (widget.tabs.length > _tabKeys.length) {
      final int delta = widget.tabs.length - _tabKeys.length;
1040
      _tabKeys.addAll(List<GlobalKey>.generate(delta, (int n) => GlobalKey()));
1041 1042
    } else if (widget.tabs.length < _tabKeys.length) {
      _tabKeys.removeRange(widget.tabs.length, _tabKeys.length);
1043
    }
1044 1045
  }

1046
  @override
Hans Muller's avatar
Hans Muller committed
1047
  void dispose() {
1048
    _indicatorPainter!.dispose();
1049
    if (_controllerIsValid) {
1050 1051
      _controller!.animation!.removeListener(_handleTabControllerAnimationTick);
      _controller!.removeListener(_handleTabControllerTick);
1052
    }
1053
    _controller = null;
Hans Muller's avatar
Hans Muller committed
1054 1055
    // We don't own the _controller Animation, so it's not disposed here.
    super.dispose();
1056
  }
1057

1058
  int get maxTabIndex => _indicatorPainter!.maxTabIndex;
1059

1060
  double _tabScrollOffset(int index, double viewportWidth, double minExtent, double maxExtent) {
1061
    if (!widget.isScrollable) {
1062
      return 0.0;
1063
    }
1064
    double tabCenter = _indicatorPainter!.centerOf(index);
1065
    double paddingStart;
1066
    switch (Directionality.of(context)) {
Ian Hickson's avatar
Ian Hickson committed
1067
      case TextDirection.rtl:
1068
        paddingStart = widget.padding?.resolve(TextDirection.rtl).right ?? 0;
Ian Hickson's avatar
Ian Hickson committed
1069 1070 1071
        tabCenter = _tabStripWidth - tabCenter;
        break;
      case TextDirection.ltr:
1072
        paddingStart = widget.padding?.resolve(TextDirection.ltr).left ?? 0;
Ian Hickson's avatar
Ian Hickson committed
1073 1074
        break;
    }
1075 1076

    return clampDouble(tabCenter + paddingStart - viewportWidth / 2.0, minExtent, maxExtent);
1077
  }
1078

1079
  double _tabCenteredScrollOffset(int index) {
1080
    final ScrollPosition position = _scrollController!.position;
1081 1082 1083 1084
    return _tabScrollOffset(index, position.viewportDimension, position.minScrollExtent, position.maxScrollExtent);
  }

  double _initialScrollOffset(double viewportWidth, double minExtent, double maxExtent) {
1085
    return _tabScrollOffset(_currentIndex!, viewportWidth, minExtent, maxExtent);
1086 1087
  }

Hans Muller's avatar
Hans Muller committed
1088
  void _scrollToCurrentIndex() {
1089 1090
    final double offset = _tabCenteredScrollOffset(_currentIndex!);
    _scrollController!.animateTo(offset, duration: kTabScrollDuration, curve: Curves.ease);
Hans Muller's avatar
Hans Muller committed
1091 1092 1093
  }

  void _scrollToControllerValue() {
1094 1095 1096
    final double? leadingPosition = _currentIndex! > 0 ? _tabCenteredScrollOffset(_currentIndex! - 1) : null;
    final double middlePosition = _tabCenteredScrollOffset(_currentIndex!);
    final double? trailingPosition = _currentIndex! < maxTabIndex ? _tabCenteredScrollOffset(_currentIndex! + 1) : null;
Hans Muller's avatar
Hans Muller committed
1097

1098 1099
    final double index = _controller!.index.toDouble();
    final double value = _controller!.animation!.value;
1100
    final double offset;
1101
    if (value == index - 1.0) {
Ian Hickson's avatar
Ian Hickson committed
1102
      offset = leadingPosition ?? middlePosition;
1103
    } else if (value == index + 1.0) {
Ian Hickson's avatar
Ian Hickson committed
1104
      offset = trailingPosition ?? middlePosition;
1105
    } else if (value == index) {
1106
      offset = middlePosition;
1107
    } else if (value < index) {
1108
      offset = leadingPosition == null ? middlePosition : lerpDouble(middlePosition, leadingPosition, index - value)!;
1109
    } else {
1110
      offset = trailingPosition == null ? middlePosition : lerpDouble(middlePosition, trailingPosition, value - index)!;
1111
    }
1112

1113
    _scrollController!.jumpTo(offset);
1114
  }
1115

1116
  void _handleTabControllerAnimationTick() {
Hans Muller's avatar
Hans Muller committed
1117
    assert(mounted);
1118
    if (!_controller!.indexIsChanging && widget.isScrollable) {
1119
      // Sync the TabBar's scroll position with the TabBarView's PageView.
1120
      _currentIndex = _controller!.index;
Hans Muller's avatar
Hans Muller committed
1121 1122
      _scrollToControllerValue();
    }
1123 1124
  }

1125
  void _handleTabControllerTick() {
1126 1127
    if (_controller!.index != _currentIndex) {
      _currentIndex = _controller!.index;
1128
      if (widget.isScrollable) {
Ian Hickson's avatar
Ian Hickson committed
1129
        _scrollToCurrentIndex();
1130
      }
Ian Hickson's avatar
Ian Hickson committed
1131
    }
1132 1133 1134 1135 1136 1137
    setState(() {
      // Rebuild the tabs after a (potentially animated) index change
      // has completed.
    });
  }

1138
  // Called each time layout completes.
Ian Hickson's avatar
Ian Hickson committed
1139 1140 1141
  void _saveTabOffsets(List<double> tabOffsets, TextDirection textDirection, double width) {
    _tabStripWidth = width;
    _indicatorPainter?.saveTabOffsets(tabOffsets, textDirection);
1142 1143
  }

Hans Muller's avatar
Hans Muller committed
1144
  void _handleTap(int index) {
1145
    assert(index >= 0 && index < widget.tabs.length);
1146
    _controller!.animateTo(index);
1147
    widget.onTap?.call(index);
Hans Muller's avatar
Hans Muller committed
1148 1149
  }

1150
  Widget _buildStyledTab(Widget child, bool selected, Animation<double> animation) {
1151
    return _TabStyle(
1152 1153
      animation: animation,
      selected: selected,
1154 1155 1156 1157
      labelColor: widget.labelColor,
      unselectedLabelColor: widget.unselectedLabelColor,
      labelStyle: widget.labelStyle,
      unselectedLabelStyle: widget.unselectedLabelStyle,
1158 1159 1160 1161
      child: child,
    );
  }

1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184
  bool _debugScheduleCheckHasValidTabsCount() {
    if (_debugHasScheduledValidTabsCountCheck) {
      return true;
    }
    WidgetsBinding.instance.addPostFrameCallback((Duration duration) {
      _debugHasScheduledValidTabsCountCheck = false;
      if (!mounted) {
        return;
      }
      assert(() {
        if (_controller!.length != widget.tabs.length) {
          throw FlutterError(
            "Controller's length property (${_controller!.length}) does not match the "
            "number of tabs (${widget.tabs.length}) present in TabBar's tabs property.",
          );
        }
        return true;
      }());
    });
    _debugHasScheduledValidTabsCountCheck = true;
    return true;
  }

Hans Muller's avatar
Hans Muller committed
1185 1186
  @override
  Widget build(BuildContext context) {
1187
    assert(debugCheckHasMaterialLocalizations(context));
1188 1189
    assert(_debugScheduleCheckHasValidTabsCount());

1190
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
1191
    if (_controller!.length == 0) {
1192
      return Container(
1193 1194 1195 1196
        height: _kTabHeight + widget.indicatorWeight,
      );
    }

1197 1198
    final TabBarTheme tabBarTheme = TabBarTheme.of(context);

1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221
    final List<Widget> wrappedTabs = List<Widget>.generate(widget.tabs.length, (int index) {
      const double verticalAdjustment = (_kTextAndIconTabHeight - _kTabHeight)/2.0;
      EdgeInsetsGeometry? adjustedPadding;

      if (widget.tabs[index] is PreferredSizeWidget) {
        final PreferredSizeWidget tab = widget.tabs[index] as PreferredSizeWidget;
        if (widget.tabHasTextAndIcon && tab.preferredSize.height == _kTabHeight) {
          if (widget.labelPadding != null || tabBarTheme.labelPadding != null) {
            adjustedPadding = (widget.labelPadding ?? tabBarTheme.labelPadding!).add(const EdgeInsets.symmetric(vertical: verticalAdjustment));
          }
          else {
            adjustedPadding = const EdgeInsets.symmetric(vertical: verticalAdjustment, horizontal: 16.0);
          }
        }
      }

      return Center(
        heightFactor: 1.0,
        child: Padding(
          padding: adjustedPadding ?? widget.labelPadding ?? tabBarTheme.labelPadding ?? kTabLabelPadding,
          child: KeyedSubtree(
            key: _tabKeys[index],
            child: widget.tabs[index],
1222
          ),
1223
        ),
1224 1225
      );
    });
Hans Muller's avatar
Hans Muller committed
1226 1227 1228 1229 1230

    // If the controller was provided by DefaultTabController and we're part
    // of a Hero (typically the AppBar), then we will not be able to find the
    // controller during a Hero transition. See https://github.com/flutter/flutter/issues/213.
    if (_controller != null) {
1231
      final int previousIndex = _controller!.previousIndex;
1232

1233
      if (_controller!.indexIsChanging) {
1234
        // The user tapped on a tab, the tab controller's animation is running.
Hans Muller's avatar
Hans Muller committed
1235
        assert(_currentIndex != previousIndex);
1236 1237
        final Animation<double> animation = _ChangeAnimation(_controller!);
        wrappedTabs[_currentIndex!] = _buildStyledTab(wrappedTabs[_currentIndex!], true, animation);
1238
        wrappedTabs[previousIndex] = _buildStyledTab(wrappedTabs[previousIndex], false, animation);
Hans Muller's avatar
Hans Muller committed
1239
      } else {
1240
        // The user is dragging the TabBarView's PageView left or right.
1241 1242
        final int tabIndex = _currentIndex!;
        final Animation<double> centerAnimation = _DragAnimation(_controller!, tabIndex);
1243
        wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], true, centerAnimation);
1244 1245 1246
        if (_currentIndex! > 0) {
          final int tabIndex = _currentIndex! - 1;
          final Animation<double> previousAnimation = ReverseAnimation(_DragAnimation(_controller!, tabIndex));
1247
          wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], false, previousAnimation);
1248
        }
1249 1250 1251
        if (_currentIndex! < widget.tabs.length - 1) {
          final int tabIndex = _currentIndex! + 1;
          final Animation<double> nextAnimation = ReverseAnimation(_DragAnimation(_controller!, tabIndex));
1252
          wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], false, nextAnimation);
1253
        }
Hans Muller's avatar
Hans Muller committed
1254
      }
Hans Muller's avatar
Hans Muller committed
1255 1256
    }

1257
    // Add the tap handler to each tab. If the tab bar is not scrollable,
1258 1259
    // then give all of the tabs equal flexibility so that they each occupy
    // the same share of the tab bar's overall width.
1260
    final int tabCount = widget.tabs.length;
Ian Hickson's avatar
Ian Hickson committed
1261
    for (int index = 0; index < tabCount; index += 1) {
1262 1263 1264 1265 1266 1267 1268 1269
      final Set<MaterialState> states = <MaterialState>{
        if (index == _currentIndex) MaterialState.selected,
      };

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

1270
      wrappedTabs[index] = InkWell(
1271
        mouseCursor: effectiveMouseCursor,
1272
        onTap: () { _handleTap(index); },
1273
        enableFeedback: widget.enableFeedback ?? true,
1274 1275
        overlayColor: widget.overlayColor ?? tabBarTheme.overlayColor,
        splashFactory: widget.splashFactory ?? tabBarTheme.splashFactory,
1276
        borderRadius: widget.splashBorderRadius,
1277 1278 1279
        child: Padding(
          padding: EdgeInsets.only(bottom: widget.indicatorWeight),
          child: Stack(
1280 1281
            children: <Widget>[
              wrappedTabs[index],
1282
              Semantics(
1283
                selected: index == _currentIndex,
1284
                label: localizations.tabLabel(tabIndex: index + 1, tabCount: tabCount),
1285
              ),
1286
            ],
1287
          ),
1288
        ),
Hans Muller's avatar
Hans Muller committed
1289
      );
1290
      if (!widget.isScrollable) {
1291
        wrappedTabs[index] = Expanded(child: wrappedTabs[index]);
1292
      }
Hans Muller's avatar
Hans Muller committed
1293 1294
    }

1295
    Widget tabBar = CustomPaint(
Hans Muller's avatar
Hans Muller committed
1296
      painter: _indicatorPainter,
1297
      child: _TabStyle(
Ian Hickson's avatar
Ian Hickson committed
1298 1299 1300 1301 1302 1303
        animation: kAlwaysDismissedAnimation,
        selected: false,
        labelColor: widget.labelColor,
        unselectedLabelColor: widget.unselectedLabelColor,
        labelStyle: widget.labelStyle,
        unselectedLabelStyle: widget.unselectedLabelStyle,
1304
        child: _TabLabelBar(
Ian Hickson's avatar
Ian Hickson committed
1305
          onPerformLayout: _saveTabOffsets,
1306
          children: wrappedTabs,
Hans Muller's avatar
Hans Muller committed
1307
        ),
Ian Hickson's avatar
Ian Hickson committed
1308
      ),
1309
    );
Hans Muller's avatar
Hans Muller committed
1310

1311
    if (widget.isScrollable) {
1312
      _scrollController ??= _TabBarScrollController(this);
1313 1314 1315 1316 1317 1318 1319
      tabBar = SingleChildScrollView(
        dragStartBehavior: widget.dragStartBehavior,
        scrollDirection: Axis.horizontal,
        controller: _scrollController,
        padding: widget.padding,
        physics: widget.physics,
        child: tabBar,
1320
      );
1321 1322 1323 1324 1325
    } else if (widget.padding != null) {
      tabBar = Padding(
        padding: widget.padding!,
        child: tabBar,
      );
1326 1327
    }

Hans Muller's avatar
Hans Muller committed
1328
    return tabBar;
1329 1330 1331
  }
}

1332
/// A page view that displays the widget which corresponds to the currently
1333 1334 1335
/// selected tab.
///
/// This widget is typically used in conjunction with a [TabBar].
Hans Muller's avatar
Hans Muller committed
1336
///
1337 1338
/// {@youtube 560 315 https://www.youtube.com/watch?v=POtoEH-5l40}
///
Hans Muller's avatar
Hans Muller committed
1339 1340
/// If a [TabController] is not provided, then there must be a [DefaultTabController]
/// ancestor.
1341 1342 1343 1344 1345
///
/// The tab controller's [TabController.length] must equal the length of the
/// [children] list and the length of the [TabBar.tabs] list.
///
/// To see a sample implementation, visit the [TabController] documentation.
Hans Muller's avatar
Hans Muller committed
1346
class TabBarView extends StatefulWidget {
1347
  /// Creates a page view with one child per tab.
Hans Muller's avatar
Hans Muller committed
1348 1349
  ///
  /// The length of [children] must be the same as the [controller]'s length.
1350
  const TabBarView({
1351
    super.key,
1352
    required this.children,
Hans Muller's avatar
Hans Muller committed
1353
    this.controller,
1354
    this.physics,
1355
    this.dragStartBehavior = DragStartBehavior.start,
1356
    this.viewportFraction = 1.0,
1357
    this.clipBehavior = Clip.hardEdge,
1358
  }) : assert(children != null),
1359
       assert(dragStartBehavior != null);
1360

Hans Muller's avatar
Hans Muller committed
1361 1362 1363 1364
  /// This widget's selection and animation state.
  ///
  /// If [TabController] is not provided, then the value of [DefaultTabController.of]
  /// will be used.
1365
  final TabController? controller;
Hans Muller's avatar
Hans Muller committed
1366

1367 1368 1369
  /// One widget per tab.
  ///
  /// Its length must match the length of the [TabBar.tabs]
1370
  /// list, as well as the [controller]'s [TabController.length].
Hans Muller's avatar
Hans Muller committed
1371 1372
  final List<Widget> children;

1373 1374 1375 1376 1377 1378 1379 1380 1381
  /// How the page view should respond to user input.
  ///
  /// For example, determines how the page view continues to animate after the
  /// user stops dragging the page view.
  ///
  /// The physics are modified to snap to page boundaries using
  /// [PageScrollPhysics] prior to being used.
  ///
  /// Defaults to matching platform conventions.
1382
  final ScrollPhysics? physics;
1383

1384 1385 1386
  /// {@macro flutter.widgets.scrollable.dragStartBehavior}
  final DragStartBehavior dragStartBehavior;

1387 1388 1389
  /// {@macro flutter.widgets.pageview.viewportFraction}
  final double viewportFraction;

1390 1391 1392 1393 1394
  /// {@macro flutter.material.Material.clipBehavior}
  ///
  /// Defaults to [Clip.hardEdge].
  final Clip clipBehavior;

1395
  @override
1396
  State<TabBarView> createState() => _TabBarViewState();
Hans Muller's avatar
Hans Muller committed
1397
}
1398

1399
class _TabBarViewState extends State<TabBarView> {
1400 1401 1402 1403 1404
  TabController? _controller;
  late PageController _pageController;
  late List<Widget> _children;
  late List<Widget> _childrenWithKey;
  int? _currentIndex;
Hans Muller's avatar
Hans Muller committed
1405
  int _warpUnderwayCount = 0;
1406
  bool _debugHasScheduledValidChildrenCountCheck = false;
Hans Muller's avatar
Hans Muller committed
1407

1408 1409 1410 1411 1412
  // If the TabBarView is rebuilt with a new tab controller, the caller should
  // dispose the old one. In that case the old controller's animation will be
  // null and should not be accessed.
  bool get _controllerIsValid => _controller?.animation != null;

Hans Muller's avatar
Hans Muller committed
1413
  void _updateTabController() {
1414
    final TabController? newController = widget.controller ?? DefaultTabController.maybeOf(context);
1415 1416
    assert(() {
      if (newController == null) {
1417
        throw FlutterError(
1418 1419
          'No TabController for ${widget.runtimeType}.\n'
          'When creating a ${widget.runtimeType}, you must either provide an explicit '
1420
          'TabController using the "controller" property, or you must ensure that there '
1421
          'is a DefaultTabController above the ${widget.runtimeType}.\n'
1422
          'In this case, there was neither an explicit controller nor a default controller.',
1423 1424 1425
        );
      }
      return true;
1426
    }());
1427

1428
    if (newController == _controller) {
Hans Muller's avatar
Hans Muller committed
1429
      return;
1430
    }
Hans Muller's avatar
Hans Muller committed
1431

1432
    if (_controllerIsValid) {
1433
      _controller!.animation!.removeListener(_handleTabControllerAnimationTick);
1434
    }
Hans Muller's avatar
Hans Muller committed
1435
    _controller = newController;
1436
    if (_controller != null) {
1437
      _controller!.animation!.addListener(_handleTabControllerAnimationTick);
1438
    }
1439 1440
  }

Hans Muller's avatar
Hans Muller committed
1441 1442 1443
  @override
  void initState() {
    super.initState();
1444
    _updateChildren();
Hans Muller's avatar
Hans Muller committed
1445 1446
  }

Hans Muller's avatar
Hans Muller committed
1447
  @override
1448 1449
  void didChangeDependencies() {
    super.didChangeDependencies();
Hans Muller's avatar
Hans Muller committed
1450
    _updateTabController();
1451
    _currentIndex = _controller!.index;
1452 1453 1454 1455
    _pageController = PageController(
      initialPage: _currentIndex!,
      viewportFraction: widget.viewportFraction,
    );
1456 1457
  }

1458
  @override
1459 1460
  void didUpdateWidget(TabBarView oldWidget) {
    super.didUpdateWidget(oldWidget);
1461
    if (widget.controller != oldWidget.controller) {
Hans Muller's avatar
Hans Muller committed
1462
      _updateTabController();
1463
      _currentIndex = _controller!.index;
1464
      _warpUnderwayCount += 1;
1465
      _pageController.jumpToPage(_currentIndex!);
1466
      _warpUnderwayCount -= 1;
1467
    }
1468
    if (widget.children != oldWidget.children && _warpUnderwayCount == 0) {
1469
      _updateChildren();
1470
    }
1471 1472
  }

1473
  @override
Hans Muller's avatar
Hans Muller committed
1474
  void dispose() {
1475
    if (_controllerIsValid) {
1476
      _controller!.animation!.removeListener(_handleTabControllerAnimationTick);
1477
    }
1478
    _controller = null;
Hans Muller's avatar
Hans Muller committed
1479 1480 1481
    // We don't own the _controller Animation, so it's not disposed here.
    super.dispose();
  }
Hans Muller's avatar
Hans Muller committed
1482

1483 1484 1485 1486 1487
  void _updateChildren() {
    _children = widget.children;
    _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children);
  }

1488
  void _handleTabControllerAnimationTick() {
1489 1490 1491
    if (_warpUnderwayCount > 0 || !_controller!.indexIsChanging) {
      return;
    } // This widget is driving the controller's animation.
Hans Muller's avatar
Hans Muller committed
1492

1493 1494
    if (_controller!.index != _currentIndex) {
      _currentIndex = _controller!.index;
Hans Muller's avatar
Hans Muller committed
1495
      _warpToCurrentIndex();
1496
    }
Hans Muller's avatar
Hans Muller committed
1497
  }
1498

1499
  Future<void> _warpToCurrentIndex() async {
1500
    if (!mounted) {
1501
      return Future<void>.value();
1502
    }
1503

1504
    if (_pageController.page == _currentIndex!.toDouble()) {
1505
      return Future<void>.value();
1506
    }
1507

1508
    final Duration duration = _controller!.animationDuration;
1509
    final int previousIndex = _controller!.previousIndex;
1510

1511
    if ((_currentIndex! - previousIndex).abs() == 1) {
1512 1513 1514 1515
      if (duration == Duration.zero) {
        _pageController.jumpToPage(_currentIndex!);
        return Future<void>.value();
      }
1516
      _warpUnderwayCount += 1;
1517
      await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
1518
      _warpUnderwayCount -= 1;
1519 1520 1521 1522

      if (mounted && widget.children != _children) {
        setState(() { _updateChildren(); });
      }
1523 1524
      return Future<void>.value();
    }
Hans Muller's avatar
Hans Muller committed
1525

1526 1527 1528 1529
    assert((_currentIndex! - previousIndex).abs() > 1);
    final int initialPage = _currentIndex! > previousIndex
        ? _currentIndex! - 1
        : _currentIndex! + 1;
1530
    final List<Widget> originalChildren = _childrenWithKey;
Hans Muller's avatar
Hans Muller committed
1531 1532
    setState(() {
      _warpUnderwayCount += 1;
1533

1534
      _childrenWithKey = List<Widget>.of(_childrenWithKey, growable: false);
1535 1536 1537 1538
      final Widget temp = _childrenWithKey[initialPage];
      _childrenWithKey[initialPage] = _childrenWithKey[previousIndex];
      _childrenWithKey[previousIndex] = temp;
    });
1539
    _pageController.jumpToPage(initialPage);
Hans Muller's avatar
Hans Muller committed
1540

1541 1542
    if (duration == Duration.zero) {
      _pageController.jumpToPage(_currentIndex!);
1543 1544
    } else {
      await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
1545

1546 1547 1548
      if (!mounted) {
        return Future<void>.value();
      }
1549
    }
1550

Hans Muller's avatar
Hans Muller committed
1551 1552
    setState(() {
      _warpUnderwayCount -= 1;
1553 1554 1555 1556 1557
      if (widget.children != _children) {
        _updateChildren();
      } else {
        _childrenWithKey = originalChildren;
      }
Hans Muller's avatar
Hans Muller committed
1558
    });
Hans Muller's avatar
Hans Muller committed
1559 1560
  }

1561
  // Called when the PageView scrolls
Adam Barth's avatar
Adam Barth committed
1562
  bool _handleScrollNotification(ScrollNotification notification) {
1563
    if (_warpUnderwayCount > 0) {
Hans Muller's avatar
Hans Muller committed
1564
      return false;
1565
    }
Hans Muller's avatar
Hans Muller committed
1566

1567
    if (notification.depth != 0) {
Hans Muller's avatar
Hans Muller committed
1568
      return false;
1569
    }
Hans Muller's avatar
Hans Muller committed
1570

1571
    _warpUnderwayCount += 1;
1572 1573
    if (notification is ScrollUpdateNotification && !_controller!.indexIsChanging) {
      if ((_pageController.page! - _controller!.index).abs() > 1.0) {
1574
        _controller!.index = _pageController.page!.round();
1575
        _currentIndex =_controller!.index;
1576
      }
1577
      _controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0);
1578
    } else if (notification is ScrollEndNotification) {
1579 1580
      _controller!.index = _pageController.page!.round();
      _currentIndex = _controller!.index;
1581
      if (!_controller!.indexIsChanging) {
1582
        _controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0);
1583
      }
Hans Muller's avatar
Hans Muller committed
1584
    }
1585
    _warpUnderwayCount -= 1;
Hans Muller's avatar
Hans Muller committed
1586 1587

    return false;
Hans Muller's avatar
Hans Muller committed
1588 1589
  }

1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612
  bool _debugScheduleCheckHasValidChildrenCount() {
    if (_debugHasScheduledValidChildrenCountCheck) {
      return true;
    }
    WidgetsBinding.instance.addPostFrameCallback((Duration duration) {
      _debugHasScheduledValidChildrenCountCheck = false;
      if (!mounted) {
        return;
      }
      assert(() {
        if (_controller!.length != widget.children.length) {
          throw FlutterError(
            "Controller's length property (${_controller!.length}) does not match the "
            "number of children (${widget.children.length}) present in TabBarView's children property.",
          );
        }
        return true;
      }());
    });
    _debugHasScheduledValidChildrenCountCheck = true;
    return true;
  }

1613
  @override
Hans Muller's avatar
Hans Muller committed
1614
  Widget build(BuildContext context) {
1615 1616
    assert(_debugScheduleCheckHasValidChildrenCount());

1617
    return NotificationListener<ScrollNotification>(
Hans Muller's avatar
Hans Muller committed
1618
      onNotification: _handleScrollNotification,
1619
      child: PageView(
1620
        dragStartBehavior: widget.dragStartBehavior,
1621
        clipBehavior: widget.clipBehavior,
1622
        controller: _pageController,
1623
        physics: widget.physics == null
1624 1625
          ? const PageScrollPhysics().applyTo(const ClampingScrollPhysics())
          : const PageScrollPhysics().applyTo(widget.physics),
1626
        children: _childrenWithKey,
Hans Muller's avatar
Hans Muller committed
1627
      ),
1628
    );
Hans Muller's avatar
Hans Muller committed
1629
  }
1630
}
Hixie's avatar
Hixie committed
1631

1632 1633
/// Displays a single circle with the specified size, border style, border color
/// and background colors.
1634 1635 1636
///
/// Used by [TabPageSelector] to indicate the selected page.
class TabPageSelectorIndicator extends StatelessWidget {
1637
  /// Creates an indicator used by [TabPageSelector].
1638
  ///
1639
  /// The [backgroundColor], [borderColor], and [size] parameters must not be null.
1640
  const TabPageSelectorIndicator({
1641
    super.key,
1642 1643 1644
    required this.backgroundColor,
    required this.borderColor,
    required this.size,
1645
    this.borderStyle = BorderStyle.solid,
1646 1647
  }) : assert(backgroundColor != null),
       assert(borderColor != null),
1648
       assert(size != null);
1649 1650 1651 1652 1653 1654 1655

  /// The indicator circle's background color.
  final Color backgroundColor;

  /// The indicator circle's border color.
  final Color borderColor;

1656 1657 1658
  /// The indicator circle's diameter.
  final double size;

1659 1660 1661 1662 1663
  /// The indicator circle's border style.
  ///
  /// Defaults to [BorderStyle.solid] if value is not specified.
  final BorderStyle borderStyle;

1664 1665
  @override
  Widget build(BuildContext context) {
1666
    return Container(
1667 1668
      width: size,
      height: size,
1669
      margin: const EdgeInsets.all(4.0),
1670
      decoration: BoxDecoration(
1671
        color: backgroundColor,
1672
        border: Border.all(color: borderColor, style: borderStyle),
1673
        shape: BoxShape.circle,
1674 1675 1676 1677 1678
      ),
    );
  }
}

1679 1680
/// Uses [TabPageSelectorIndicator] to display a row of small circular
/// indicators, one per tab.
1681
///
1682 1683
/// {@youtube 560 315 https://www.youtube.com/watch?v=Q628ue9Cq7U}
///
1684 1685 1686 1687 1688
/// The selected tab's indicator is highlighted. Often used in conjunction with
/// a [TabBarView].
///
/// If a [TabController] is not provided, then there must be a
/// [DefaultTabController] ancestor.
Hans Muller's avatar
Hans Muller committed
1689 1690
class TabPageSelector extends StatelessWidget {
  /// Creates a compact widget that indicates which tab has been selected.
1691
  const TabPageSelector({
1692
    super.key,
1693
    this.controller,
1694
    this.indicatorSize = 12.0,
1695 1696
    this.color,
    this.selectedColor,
1697
    this.borderStyle,
1698
  }) : assert(indicatorSize != null && indicatorSize > 0.0);
Hixie's avatar
Hixie committed
1699

Hans Muller's avatar
Hans Muller committed
1700 1701
  /// This widget's selection and animation state.
  ///
1702 1703
  /// If [TabController] is not provided, then the value of
  /// [DefaultTabController.of] will be used.
1704
  final TabController? controller;
Hans Muller's avatar
Hans Muller committed
1705

1706 1707 1708
  /// The indicator circle's diameter (the default value is 12.0).
  final double indicatorSize;

1709
  /// The indicator circle's fill color for unselected pages.
1710
  ///
1711
  /// If this parameter is null, then the indicator is filled with [Colors.transparent].
1712
  final Color? color;
1713

1714
  /// The indicator circle's fill color for selected pages and border color
1715 1716
  /// for all indicator circles.
  ///
1717
  /// If this parameter is null, then the indicator is filled with the theme's
1718
  /// [ColorScheme.secondary].
1719
  final Color? selectedColor;
1720

1721 1722 1723 1724 1725
  /// The indicator circle's border style.
  ///
  /// Defaults to [BorderStyle.solid] if value is not specified.
  final BorderStyle? borderStyle;

Hans Muller's avatar
Hans Muller committed
1726 1727 1728
  Widget _buildTabIndicator(
    int tabIndex,
    TabController tabController,
1729 1730
    ColorTween selectedColorTween,
    ColorTween previousColorTween,
Hans Muller's avatar
Hans Muller committed
1731
  ) {
1732
    final Color background;
Hans Muller's avatar
Hans Muller committed
1733
    if (tabController.indexIsChanging) {
Hixie's avatar
Hixie committed
1734
      // The selection's animation is animating from previousValue to value.
1735
      final double t = 1.0 - _indexChangeProgress(tabController);
1736
      if (tabController.index == tabIndex) {
1737
        background = selectedColorTween.lerp(t)!;
1738
      } else if (tabController.previousIndex == tabIndex) {
1739
        background = previousColorTween.lerp(t)!;
1740
      } else {
1741
        background = selectedColorTween.begin!;
1742
      }
Hixie's avatar
Hixie committed
1743
    } else {
Ian Hickson's avatar
Ian Hickson committed
1744 1745
      // The selection's offset reflects how far the TabBarView has / been dragged
      // to the previous page (-1.0 to 0.0) or the next page (0.0 to 1.0).
1746 1747
      final double offset = tabController.offset;
      if (tabController.index == tabIndex) {
1748
        background = selectedColorTween.lerp(1.0 - offset.abs())!;
1749
      } else if (tabController.index == tabIndex - 1 && offset > 0.0) {
1750
        background = selectedColorTween.lerp(offset)!;
1751
      } else if (tabController.index == tabIndex + 1 && offset < 0.0) {
1752
        background = selectedColorTween.lerp(-offset)!;
1753
      } else {
1754
        background = selectedColorTween.begin!;
1755
      }
Hixie's avatar
Hixie committed
1756
    }
1757
    return TabPageSelectorIndicator(
1758
      backgroundColor: background,
1759
      borderColor: selectedColorTween.end!,
1760
      size: indicatorSize,
1761
      borderStyle: borderStyle ?? BorderStyle.solid,
Hixie's avatar
Hixie committed
1762 1763 1764
    );
  }

1765
  @override
Hixie's avatar
Hixie committed
1766
  Widget build(BuildContext context) {
1767
    final Color fixColor = color ?? Colors.transparent;
1768
    final Color fixSelectedColor = selectedColor ?? Theme.of(context).colorScheme.secondary;
1769 1770
    final ColorTween selectedColorTween = ColorTween(begin: fixColor, end: fixSelectedColor);
    final ColorTween previousColorTween = ColorTween(begin: fixSelectedColor, end: fixColor);
1771
    final TabController? tabController = controller ?? DefaultTabController.maybeOf(context);
1772
	  final MaterialLocalizations localizations = MaterialLocalizations.of(context);
1773 1774
    assert(() {
      if (tabController == null) {
1775
        throw FlutterError(
1776 1777 1778 1779
          'No TabController for $runtimeType.\n'
          'When creating a $runtimeType, you must either provide an explicit TabController '
          'using the "controller" property, or you must ensure that there is a '
          'DefaultTabController above the $runtimeType.\n'
1780
          'In this case, there was neither an explicit controller nor a default controller.',
1781 1782 1783
        );
      }
      return true;
1784
    }());
1785
    final Animation<double> animation = CurvedAnimation(
1786
      parent: tabController!.animation!,
Hans Muller's avatar
Hans Muller committed
1787 1788
      curve: Curves.fastOutSlowIn,
    );
1789
    return AnimatedBuilder(
Hixie's avatar
Hixie committed
1790
      animation: animation,
1791
      builder: (BuildContext context, Widget? child) {
1792
        return Semantics(
1793
          label: localizations.tabLabel(tabIndex: tabController.index + 1, tabCount: tabController.length),
1794
          child: Row(
Hans Muller's avatar
Hans Muller committed
1795
            mainAxisSize: MainAxisSize.min,
1796
            children: List<Widget>.generate(tabController.length, (int tabIndex) {
1797
              return _buildTabIndicator(tabIndex, tabController, selectedColorTween, previousColorTween);
Hans Muller's avatar
Hans Muller committed
1798 1799
            }).toList(),
          ),
Hixie's avatar
Hixie committed
1800
        );
1801
      },
Hixie's avatar
Hixie committed
1802 1803 1804
    );
  }
}