tabs.dart 34.6 KB
Newer Older
1 2 3 4
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

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

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

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

const double _kTabHeight = 46.0;
const double _kTextAndIconTabHeight = 72.0;
const double _kTabIndicatorHeight = 2.0;
const double _kMinTabWidth = 72.0;
const double _kMaxTabWidth = 264.0;
26
const EdgeInsets _kTabLabelPadding = const EdgeInsets.symmetric(horizontal: 12.0);
27

Hans Muller's avatar
Hans Muller committed
28 29 30 31 32 33 34 35 36 37 38 39
/// A material design [TabBar] tab. If both [icon] and [text] are
/// provided, the text is displayed below the icon.
///
/// 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].
///  * <https://material.google.com/components/tabs.html>
class Tab extends StatelessWidget {
  /// Creates a material design [TabBar] tab. At least one of [text] and [icon]
  /// must be non-null.
40
  const Tab({
41
    Key key,
Hans Muller's avatar
Hans Muller committed
42 43
    this.text,
    this.icon,
44 45
  }) : assert(text != null || icon != null),
       super(key: key);
46

Hans Muller's avatar
Hans Muller committed
47
  /// The text to display as the tab's label.
48
  final String text;
49

Hans Muller's avatar
Hans Muller committed
50
  /// An icon to display as the tab's label.
Ian Hickson's avatar
Ian Hickson committed
51
  final Widget icon;
52

53
  Widget _buildLabelText() {
Hans Muller's avatar
Hans Muller committed
54
    return new Text(text, softWrap: false, overflow: TextOverflow.fade);
55 56
  }

57
  @override
58
  Widget build(BuildContext context) {
59
    assert(debugCheckHasMaterial(context));
Hans Muller's avatar
Hans Muller committed
60 61 62 63 64 65 66 67 68

    double height;
    Widget label;
    if (icon == null) {
      height = _kTabHeight;
      label = _buildLabelText();
    } else if (text == null) {
      height = _kTabHeight;
      label = icon;
69
    } else {
Hans Muller's avatar
Hans Muller committed
70 71
      height = _kTextAndIconTabHeight;
      label = new Column(
72 73
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
74
        children: <Widget>[
75
          new Container(
Hans Muller's avatar
Hans Muller committed
76
            child: icon,
77
            margin: const EdgeInsets.only(bottom: 10.0)
78 79
          ),
          _buildLabelText()
80
        ]
81 82 83
      );
    }

Hans Muller's avatar
Hans Muller committed
84 85 86
    return new Container(
      padding: _kTabLabelPadding,
      height: height,
87
      constraints: const BoxConstraints(minWidth: _kMinTabWidth),
Hans Muller's avatar
Hans Muller committed
88
      child: new Center(child: label),
89
    );
90
  }
Hixie's avatar
Hixie committed
91

92
  @override
Hixie's avatar
Hixie committed
93 94
  void debugFillDescription(List<String> description) {
    super.debugFillDescription(description);
Hans Muller's avatar
Hans Muller committed
95 96 97 98
    if (text != null)
      description.add('text: $text');
    if (icon != null)
      description.add('icon: $icon');
Hixie's avatar
Hixie committed
99
  }
100 101
}

Hans Muller's avatar
Hans Muller committed
102
class _TabStyle extends AnimatedWidget {
103
  const _TabStyle({
Hans Muller's avatar
Hans Muller committed
104 105 106 107
    Key key,
    Animation<double> animation,
    this.selected,
    this.labelColor,
108
    this.unselectedLabelColor,
109 110
    this.labelStyle,
    this.unselectedLabelStyle,
111
    @required this.child,
112
  }) : super(key: key, listenable: animation);
113

114 115
  final TextStyle labelStyle;
  final TextStyle unselectedLabelStyle;
Hans Muller's avatar
Hans Muller committed
116 117
  final bool selected;
  final Color labelColor;
118
  final Color unselectedLabelColor;
Hans Muller's avatar
Hans Muller committed
119
  final Widget child;
120

121
  @override
Hans Muller's avatar
Hans Muller committed
122 123
  Widget build(BuildContext context) {
    final ThemeData themeData = Theme.of(context);
124 125 126 127 128
    final TextStyle defaultStyle = labelStyle ?? themeData.primaryTextTheme.body2;
    final TextStyle defaultUnselectedStyle = unselectedLabelStyle ?? labelStyle ?? themeData.primaryTextTheme.body2;
    final TextStyle textStyle = selected
      ? defaultStyle
      : defaultUnselectedStyle;
Hans Muller's avatar
Hans Muller committed
129
    final Color selectedColor = labelColor ?? themeData.primaryTextTheme.body2.color;
130
    final Color unselectedColor = unselectedLabelColor ?? selectedColor.withAlpha(0xB2); // 70% alpha
131
    final Animation<double> animation = listenable;
Hans Muller's avatar
Hans Muller committed
132
    final Color color = selected
133 134
      ? Color.lerp(selectedColor, unselectedColor, animation.value)
      : Color.lerp(unselectedColor, selectedColor, animation.value);
Hans Muller's avatar
Hans Muller committed
135 136 137

    return new DefaultTextStyle(
      style: textStyle.copyWith(color: color),
138
      child: IconTheme.merge(
Hans Muller's avatar
Hans Muller committed
139 140 141 142 143 144
        data: new IconThemeData(
          size: 24.0,
          color: color,
        ),
        child: child,
      ),
145 146 147 148
    );
  }
}

Hans Muller's avatar
Hans Muller committed
149 150 151 152 153 154 155 156
class _TabLabelBarRenderer extends RenderFlex {
  _TabLabelBarRenderer({
    List<RenderBox> children,
    Axis direction,
    MainAxisSize mainAxisSize,
    MainAxisAlignment mainAxisAlignment,
    CrossAxisAlignment crossAxisAlignment,
    TextBaseline textBaseline,
157
    @required this.onPerformLayout,
158 159 160 161 162 163 164 165 166
  }) : assert(onPerformLayout != null),
       super(
         children: children,
         direction: direction,
         mainAxisSize: mainAxisSize,
         mainAxisAlignment: mainAxisAlignment,
         crossAxisAlignment: crossAxisAlignment,
         textBaseline: textBaseline,
       );
167

Hans Muller's avatar
Hans Muller committed
168
  ValueChanged<List<double>> onPerformLayout;
169 170

  @override
Hans Muller's avatar
Hans Muller committed
171 172 173 174 175 176 177 178 179 180 181 182
  void performLayout() {
    super.performLayout();
    RenderBox child = firstChild;
    final List<double> xOffsets = <double>[];
    while (child != null) {
      final FlexParentData childParentData = child.parentData;
      xOffsets.add(childParentData.offset.dx);
      assert(child.parentData == childParentData);
      child = childParentData.nextSibling;
    }
    xOffsets.add(size.width); // So xOffsets[lastTabIndex + 1] is valid.
    onPerformLayout(xOffsets);
183
  }
Hans Muller's avatar
Hans Muller committed
184 185
}

Hans Muller's avatar
Hans Muller committed
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215
// 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({
    Key key,
    MainAxisAlignment mainAxisAlignment,
    CrossAxisAlignment crossAxisAlignment,
    List<Widget> children: const <Widget>[],
    this.onPerformLayout,
  }) : super(
    key: key,
    children: children,
    direction: Axis.horizontal,
    mainAxisSize: MainAxisSize.max,
    mainAxisAlignment: MainAxisAlignment.start,
    crossAxisAlignment: CrossAxisAlignment.center,
  );

  final ValueChanged<List<double>> onPerformLayout;

  @override
  RenderFlex createRenderObject(BuildContext context) {
    return new _TabLabelBarRenderer(
      direction: direction,
      mainAxisAlignment: mainAxisAlignment,
      mainAxisSize: mainAxisSize,
      crossAxisAlignment: crossAxisAlignment,
      textBaseline: textBaseline,
      onPerformLayout: onPerformLayout,
216
    );
217 218
  }

219
  @override
Hans Muller's avatar
Hans Muller committed
220 221 222
  void updateRenderObject(BuildContext context, _TabLabelBarRenderer renderObject) {
    super.updateRenderObject(context, renderObject);
    renderObject.onPerformLayout = onPerformLayout;
Hans Muller's avatar
Hans Muller committed
223
  }
Hans Muller's avatar
Hans Muller committed
224
}
Hans Muller's avatar
Hans Muller committed
225

Hans Muller's avatar
Hans Muller committed
226 227 228 229
double _indexChangeProgress(TabController controller) {
  final double controllerValue = controller.animation.value;
  final double previousIndex = controller.previousIndex.toDouble();
  final double currentIndex = controller.index.toDouble();
230 231 232 233 234 235 236 237

  // The controller's offset is changing because the user is dragging the
  // TabBarView's PageView to the left or right.
  if (!controller.indexIsChanging)
    return (currentIndex -  controllerValue).abs().clamp(0.0, 1.0);

  // 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
238
}
239

Hans Muller's avatar
Hans Muller committed
240 241
class _IndicatorPainter extends CustomPainter {
  _IndicatorPainter(this.controller) : super(repaint: controller.animation);
242

Hans Muller's avatar
Hans Muller committed
243 244 245 246
  TabController controller;
  List<double> tabOffsets;
  Color color;
  Rect currentRect;
247

Hans Muller's avatar
Hans Muller committed
248 249 250
  // tabOffsets[index] is the offset of the left edge of the tab at index, and
  // tabOffsets[tabOffsets.length] is the right edge of the last tab.
  int get maxTabIndex => tabOffsets.length - 2;
251

Hans Muller's avatar
Hans Muller committed
252 253 254 255 256 257
  Rect indicatorRect(Size tabBarSize, int tabIndex) {
    assert(tabOffsets != null && tabIndex >= 0 && tabIndex <= maxTabIndex);
    final double tabLeft = tabOffsets[tabIndex];
    final double tabRight = tabOffsets[tabIndex + 1];
    final double tabTop = tabBarSize.height - _kTabIndicatorHeight;
    return new Rect.fromLTWH(tabLeft, tabTop, tabRight - tabLeft, _kTabIndicatorHeight);
258 259
  }

Hans Muller's avatar
Hans Muller committed
260 261 262 263
  @override
  void paint(Canvas canvas, Size size) {
    if (controller.indexIsChanging) {
      final Rect targetRect = indicatorRect(size, controller.index);
264
      currentRect = Rect.lerp(targetRect, currentRect ?? targetRect, _indexChangeProgress(controller));
Hans Muller's avatar
Hans Muller committed
265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285
    } else {
      final int currentIndex = controller.index;
      final Rect left = currentIndex > 0 ? indicatorRect(size, currentIndex - 1) : null;
      final Rect middle = indicatorRect(size, currentIndex);
      final Rect right = currentIndex < maxTabIndex ? indicatorRect(size, currentIndex + 1) : null;

      final double index = controller.index.toDouble();
      final double value = controller.animation.value;
      if (value == index - 1.0)
        currentRect = left ?? middle;
      else if (value == index + 1.0)
        currentRect = right ?? middle;
      else if (value == index)
         currentRect = middle;
      else if (value < index)
        currentRect = left == null ? middle : Rect.lerp(middle, left, index - value);
      else
        currentRect = right == null ? middle : Rect.lerp(middle, right, value - index);
    }
    assert(currentRect != null);
    canvas.drawRect(currentRect, new Paint()..color = color);
286 287
  }

Hans Muller's avatar
Hans Muller committed
288 289 290 291 292
  static bool tabOffsetsNotEqual(List<double> a, List<double> b) {
    assert(a != null && b != null && a.length == b.length);
    for(int i = 0; i < a.length; i++) {
      if (a[i] != b[i])
        return true;
293
    }
Hans Muller's avatar
Hans Muller committed
294
    return false;
295 296
  }

297
  @override
Hans Muller's avatar
Hans Muller committed
298 299 300
  bool shouldRepaint(_IndicatorPainter old) {
    return controller != old.controller ||
      tabOffsets?.length != old.tabOffsets?.length ||
301 302
      tabOffsetsNotEqual(tabOffsets, old.tabOffsets) ||
      currentRect != old.currentRect;
Hans Muller's avatar
Hans Muller committed
303
  }
304 305
}

Hans Muller's avatar
Hans Muller committed
306 307
class _ChangeAnimation extends Animation<double> with AnimationWithParentMixin<double> {
  _ChangeAnimation(this.controller);
308

Hans Muller's avatar
Hans Muller committed
309
  final TabController controller;
310 311

  @override
Hans Muller's avatar
Hans Muller committed
312 313 314 315
  Animation<double> get parent => controller.animation;

  @override
  double get value => _indexChangeProgress(controller);
316 317
}

318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333
class _DragAnimation extends Animation<double> with AnimationWithParentMixin<double> {
  _DragAnimation(this.controller, this.index);

  final TabController controller;
  final int index;

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

  @override
  double get value {
    assert(!controller.indexIsChanging);
    return (controller.animation.value - index.toDouble()).abs().clamp(0.0, 1.0);
  }
}

334 335 336 337
// This class, and TabBarScrollController, only exist to handle the the case
// 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.
338
class _TabBarScrollPosition extends ScrollPositionWithSingleContext {
339 340
  _TabBarScrollPosition({
    ScrollPhysics physics,
341
    ScrollContext context,
342 343 344 345
    ScrollPosition oldPosition,
    this.tabBar,
  }) : super(
    physics: physics,
346
    context: context,
347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371
    initialPixels: null,
    oldPosition: oldPosition,
  );

  final _TabBarState tabBar;

  @override
  bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
    bool result = true;
    if (pixels == null) {
      correctPixels(tabBar._initialScrollOffset(viewportDimension, minScrollExtent, maxScrollExtent));
      result = false;
    }
    return super.applyContentDimensions(minScrollExtent, maxScrollExtent) && result;
  }
}

// This class, and TabBarScrollPosition, only exist to handle the the case
// where a scrollable TabBar has a non-zero initialIndex.
class _TabBarScrollController extends ScrollController {
  _TabBarScrollController(this.tabBar);

  final _TabBarState tabBar;

  @override
372
  ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition oldPosition) {
373 374
    return new _TabBarScrollPosition(
      physics: physics,
375
      context: context,
376 377 378 379 380 381
      oldPosition: oldPosition,
      tabBar: tabBar,
    );
  }
}

382
/// A material design widget that displays a horizontal row of tabs.
383
///
384 385
/// Typically created as part of an [AppBar] and in conjuction with a
/// [TabBarView].
386
///
387 388 389 390 391 392 393 394 395
/// If a [TabController] is not provided, then there must be a
/// [DefaultTabController] ancestor.
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
///
///  * [TabBarView], which displays the contents that the tab bar is selecting
///    between.
396
class TabBar extends StatefulWidget implements PreferredSizeWidget {
397 398
  /// Creates a material design tab bar.
  ///
399
  /// The [tabs] argument must not be null and must have more than one widget.
400 401 402
  ///
  /// If a [TabController] is not provided, then there must be a
  /// [DefaultTabController] ancestor.
403
  TabBar({
404
    Key key,
Hans Muller's avatar
Hans Muller committed
405 406
    @required this.tabs,
    this.controller,
407 408
    this.isScrollable: false,
    this.indicatorColor,
Hans Muller's avatar
Hans Muller committed
409
    this.labelColor,
410
    this.labelStyle,
411
    this.unselectedLabelColor,
412
    this.unselectedLabelStyle,
413 414 415
  }) : assert(tabs != null && tabs.length > 1),
       assert(isScrollable != null),
       super(key: key);
416

Hans Muller's avatar
Hans Muller committed
417 418 419 420
  /// Typically a list of [Tab] widgets.
  final List<Widget> tabs;

  /// This widget's selection and animation state.
421
  ///
Hans Muller's avatar
Hans Muller committed
422 423 424
  /// If [TabController] is not provided, then the value of [DefaultTabController.of]
  /// will be used.
  final TabController controller;
425 426 427 428 429 430

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

433 434 435 436
  /// 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.
  final Color indicatorColor;

437 438 439 440 441 442 443
  /// The color of selected tab labels.
  ///
  /// Unselected tab labels are rendered with the same color rendered at 70%
  /// opacity unless [unselectedLabelColor] is non-null.
  ///
  /// If this parameter is null then the color of the theme's body2 text color
  /// is used.
444 445
  final Color labelColor;

446 447 448 449 450 451
  /// The color of unselected tab labels.
  ///
  /// If this property is null, Unselected tab labels are rendered with the
  /// [labelColor] rendered at 70% opacity.
  final Color unselectedLabelColor;

452 453 454 455 456 457 458 459 460 461 462 463 464 465
  /// The text style of the selected tab labels. 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 theme's body2
  /// definition is used.
  final TextStyle labelStyle;

  /// The text style of the unselected tab labels
  ///
  /// If this property is null then the [labelStyle] value is used. If [labelStyle]
  /// is null then the text style of the theme's body2 definition is used.
  final TextStyle unselectedLabelStyle;

466 467 468
  /// A size whose height depends on if the tabs have both icons and text.
  ///
  /// [AppBar] uses this this size to compute its own preferred size.
469
  @override
470
  Size get preferredSize {
471 472 473
    for (Widget item in tabs) {
      if (item is Tab) {
        final Tab tab = item;
Hans Muller's avatar
Hans Muller committed
474
        if (tab.text != null && tab.icon != null)
475
          return const Size.fromHeight(_kTextAndIconTabHeight + _kTabIndicatorHeight);
Hans Muller's avatar
Hans Muller committed
476
      }
477
    }
478
    return const Size.fromHeight(_kTabHeight + _kTabIndicatorHeight);
479 480
  }

481
  @override
Hans Muller's avatar
Hans Muller committed
482
  _TabBarState createState() => new _TabBarState();
483
}
484

Hans Muller's avatar
Hans Muller committed
485
class _TabBarState extends State<TabBar> {
486
  ScrollController _scrollController;
Hans Muller's avatar
Hans Muller committed
487

Hans Muller's avatar
Hans Muller committed
488 489 490 491 492
  TabController _controller;
  _IndicatorPainter _indicatorPainter;
  int _currentIndex;

  void _updateTabController() {
493
    final TabController newController = widget.controller ?? DefaultTabController.of(context);
494 495 496
    assert(() {
      if (newController == null) {
        throw new FlutterError(
497 498
          'No TabController for ${widget.runtimeType}.\n'
          'When creating a ${widget.runtimeType}, you must either provide an explicit '
499
          'TabController using the "controller" property, or you must ensure that there '
500
          'is a DefaultTabController above the ${widget.runtimeType}.\n'
501 502 503 504 505
          'In this case, there was neither an explicit controller nor a default controller.'
        );
      }
      return true;
    });
Hans Muller's avatar
Hans Muller committed
506
    if (newController == _controller)
507
      return;
Hans Muller's avatar
Hans Muller committed
508

509 510 511 512
    if (_controller != null) {
      _controller.animation.removeListener(_handleTabControllerAnimationTick);
      _controller.removeListener(_handleTabControllerTick);
    }
Hans Muller's avatar
Hans Muller committed
513 514
    _controller = newController;
    if (_controller != null) {
515 516
      _controller.animation.addListener(_handleTabControllerAnimationTick);
      _controller.addListener(_handleTabControllerTick);
Hans Muller's avatar
Hans Muller committed
517 518 519
      _currentIndex = _controller.index;
      final List<double> offsets = _indicatorPainter?.tabOffsets;
      _indicatorPainter = new _IndicatorPainter(_controller)..tabOffsets = offsets;
520
    }
521 522
  }

523
  @override
524 525
  void didChangeDependencies() {
    super.didChangeDependencies();
Hans Muller's avatar
Hans Muller committed
526
    _updateTabController();
527 528
  }

529
  @override
530 531 532
  void didUpdateWidget(TabBar oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.controller != oldWidget.controller)
Hans Muller's avatar
Hans Muller committed
533
      _updateTabController();
534 535
  }

536
  @override
Hans Muller's avatar
Hans Muller committed
537
  void dispose() {
538
    if (_controller != null) {
539
      _controller.animation.removeListener(_handleTabControllerAnimationTick);
540 541
      _controller.removeListener(_handleTabControllerTick);
    }
Hans Muller's avatar
Hans Muller committed
542 543
    // We don't own the _controller Animation, so it's not disposed here.
    super.dispose();
544
  }
545

Hans Muller's avatar
Hans Muller committed
546 547 548
  // tabOffsets[index] is the offset of the left edge of the tab at index, and
  // tabOffsets[tabOffsets.length] is the right edge of the last tab.
  int get maxTabIndex => _indicatorPainter.tabOffsets.length - 2;
549

550 551 552
  double _tabScrollOffset(int index, double viewportWidth, double minExtent, double maxExtent) {
    if (!widget.isScrollable)
      return 0.0;
Hans Muller's avatar
Hans Muller committed
553
    final List<double> tabOffsets = _indicatorPainter.tabOffsets;
554 555 556 557
    assert(tabOffsets != null && index >= 0 && index <= maxTabIndex);
    final double tabCenter = (tabOffsets[index] + tabOffsets[index + 1]) / 2.0;
    return (tabCenter - viewportWidth / 2.0).clamp(minExtent, maxExtent);
  }
558

559
  double _tabCenteredScrollOffset(int index) {
560
    final ScrollPosition position = _scrollController.position;
561 562 563 564 565
    return _tabScrollOffset(index, position.viewportDimension, position.minScrollExtent, position.maxScrollExtent);
  }

  double _initialScrollOffset(double viewportWidth, double minExtent, double maxExtent) {
    return _tabScrollOffset(_currentIndex, viewportWidth, minExtent, maxExtent);
566 567
  }

Hans Muller's avatar
Hans Muller committed
568
  void _scrollToCurrentIndex() {
569 570
    final double offset = _tabCenteredScrollOffset(_currentIndex);
    _scrollController.animateTo(offset, duration: kTabScrollDuration, curve: Curves.ease);
Hans Muller's avatar
Hans Muller committed
571 572 573
  }

  void _scrollToControllerValue() {
574 575 576
    final double left = _currentIndex > 0 ? _tabCenteredScrollOffset(_currentIndex - 1) : null;
    final double middle = _tabCenteredScrollOffset(_currentIndex);
    final double right = _currentIndex < maxTabIndex ? _tabCenteredScrollOffset(_currentIndex + 1) : null;
Hans Muller's avatar
Hans Muller committed
577 578 579 580 581 582 583 584 585 586 587 588 589 590

    final double index = _controller.index.toDouble();
    final double value = _controller.animation.value;
    double offset;
    if (value == index - 1.0)
      offset = left ?? middle;
    else if (value == index + 1.0)
      offset = right ?? middle;
    else if (value == index)
       offset = middle;
    else if (value < index)
      offset = left == null ? middle : lerpDouble(middle, left, index - value);
    else
      offset = right == null ? middle : lerpDouble(middle, right, value - index);
591

592
    _scrollController.jumpTo(offset);
593
  }
594

595
  void _handleTabControllerAnimationTick() {
Hans Muller's avatar
Hans Muller committed
596
    assert(mounted);
597
    if (!_controller.indexIsChanging && widget.isScrollable) {
598 599
      // Sync the TabBar's scroll position with the TabBarView's PageView.
      _currentIndex = _controller.index;
Hans Muller's avatar
Hans Muller committed
600 601
      _scrollToControllerValue();
    }
602 603
  }

604 605 606 607 608 609 610
  void _handleTabControllerTick() {
    setState(() {
      // Rebuild the tabs after a (potentially animated) index change
      // has completed.
    });
  }

611
  // Called each time layout completes.
Hans Muller's avatar
Hans Muller committed
612 613
  void _saveTabOffsets(List<double> tabOffsets) {
    _indicatorPainter?.tabOffsets = tabOffsets;
614 615
  }

Hans Muller's avatar
Hans Muller committed
616
  void _handleTap(int index) {
617
    assert(index >= 0 && index < widget.tabs.length);
Hans Muller's avatar
Hans Muller committed
618
    _controller.animateTo(index);
Hans Muller's avatar
Hans Muller committed
619 620
  }

621 622 623 624
  Widget _buildStyledTab(Widget child, bool selected, Animation<double> animation) {
    return new _TabStyle(
      animation: animation,
      selected: selected,
625 626 627 628
      labelColor: widget.labelColor,
      unselectedLabelColor: widget.unselectedLabelColor,
      labelStyle: widget.labelStyle,
      unselectedLabelStyle: widget.unselectedLabelStyle,
629 630 631 632
      child: child,
    );
  }

Hans Muller's avatar
Hans Muller committed
633 634
  @override
  Widget build(BuildContext context) {
635
    final List<Widget> wrappedTabs = new List<Widget>.from(widget.tabs, growable: false);
Hans Muller's avatar
Hans Muller committed
636 637 638 639 640

    // 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) {
641
      _indicatorPainter.color = widget.indicatorColor ?? Theme.of(context).indicatorColor;
Hans Muller's avatar
Hans Muller committed
642 643 644 645 646 647 648 649 650
      if (_indicatorPainter.color == Material.of(context).color) {
        // 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 clashing, 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.
        _indicatorPainter.color = Colors.white;
651
      }
652

Hans Muller's avatar
Hans Muller committed
653 654
      if (_controller.index != _currentIndex) {
        _currentIndex = _controller.index;
655
        if (widget.isScrollable)
Hans Muller's avatar
Hans Muller committed
656 657
          _scrollToCurrentIndex();
      }
Hans Muller's avatar
Hans Muller committed
658

Hans Muller's avatar
Hans Muller committed
659
      final int previousIndex = _controller.previousIndex;
660

Hans Muller's avatar
Hans Muller committed
661
      if (_controller.indexIsChanging) {
662
        // The user tapped on a tab, the tab controller's animation is running.
Hans Muller's avatar
Hans Muller committed
663
        assert(_currentIndex != previousIndex);
664 665 666
        final Animation<double> animation = new _ChangeAnimation(_controller);
        wrappedTabs[_currentIndex] = _buildStyledTab(wrappedTabs[_currentIndex], true, animation);
        wrappedTabs[previousIndex] = _buildStyledTab(wrappedTabs[previousIndex], false, animation);
Hans Muller's avatar
Hans Muller committed
667
      } else {
668 669 670 671 672 673 674 675 676
        // The user is dragging the TabBarView's PageView left or right.
        final int tabIndex = _currentIndex;
        final Animation<double> centerAnimation = new _DragAnimation(_controller, tabIndex);
        wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], true, centerAnimation);
        if (_currentIndex > 0) {
          final int tabIndex = _currentIndex - 1;
          final Animation<double> leftAnimation = new _DragAnimation(_controller, tabIndex);
          wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], true, leftAnimation);
        }
677
        if (_currentIndex < widget.tabs.length - 1) {
678 679 680 681
          final int tabIndex = _currentIndex + 1;
          final Animation<double> rightAnimation = new _DragAnimation(_controller, tabIndex);
          wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], true, rightAnimation);
        }
Hans Muller's avatar
Hans Muller committed
682
      }
Hans Muller's avatar
Hans Muller committed
683 684
    }

Hans Muller's avatar
Hans Muller committed
685 686 687
    // Add the tap handler to each tab. If the tab bar is scrollable
    // then give all of the tabs equal flexibility so that their widths
    // reflect the intrinsic width of their labels.
688
    for (int index = 0; index < widget.tabs.length; index++) {
Hans Muller's avatar
Hans Muller committed
689 690 691 692
      wrappedTabs[index] = new InkWell(
        onTap: () { _handleTap(index); },
        child: wrappedTabs[index],
      );
693
      if (!widget.isScrollable)
694
        wrappedTabs[index] = new Expanded(child: wrappedTabs[index]);
Hans Muller's avatar
Hans Muller committed
695 696 697 698 699 700 701
    }

    Widget tabBar = new CustomPaint(
      painter: _indicatorPainter,
      child: new Padding(
        padding: const EdgeInsets.only(bottom: _kTabIndicatorHeight),
        child: new _TabStyle(
702
          animation: kAlwaysDismissedAnimation,
Hans Muller's avatar
Hans Muller committed
703
          selected: false,
704 705 706 707
          labelColor: widget.labelColor,
          unselectedLabelColor: widget.unselectedLabelColor,
          labelStyle: widget.labelStyle,
          unselectedLabelStyle: widget.unselectedLabelStyle,
Hans Muller's avatar
Hans Muller committed
708 709 710 711 712 713
          child: new _TabLabelBar(
            onPerformLayout: _saveTabOffsets,
            children:  wrappedTabs,
          ),
        ),
      ),
714
    );
Hans Muller's avatar
Hans Muller committed
715

716
    if (widget.isScrollable) {
717
      _scrollController ??= new _TabBarScrollController(this);
718
      tabBar = new SingleChildScrollView(
Hans Muller's avatar
Hans Muller committed
719
        scrollDirection: Axis.horizontal,
720 721
        controller: _scrollController,
        child: tabBar,
722 723 724
      );
    }

Hans Muller's avatar
Hans Muller committed
725
    return tabBar;
726 727 728
  }
}

729
/// A page view that displays the widget which corresponds to the currently
Hans Muller's avatar
Hans Muller committed
730 731 732 733 734
/// selected tab. Typically used in conjuction with a [TabBar].
///
/// If a [TabController] is not provided, then there must be a [DefaultTabController]
/// ancestor.
class TabBarView extends StatefulWidget {
735
  /// Creates a page view with one child per tab.
Hans Muller's avatar
Hans Muller committed
736 737 738 739 740 741
  ///
  /// The length of [children] must be the same as the [controller]'s length.
  TabBarView({
    Key key,
    @required this.children,
    this.controller,
742 743
  }) : assert(children != null && children.length > 1),
       super(key: key);
744

Hans Muller's avatar
Hans Muller committed
745 746 747 748 749 750 751 752 753
  /// This widget's selection and animation state.
  ///
  /// If [TabController] is not provided, then the value of [DefaultTabController.of]
  /// will be used.
  final TabController controller;

  /// One widget per tab.
  final List<Widget> children;

754
  @override
Hans Muller's avatar
Hans Muller committed
755 756
  _TabBarViewState createState() => new _TabBarViewState();
}
757

758
final PageScrollPhysics _kTabBarViewPhysics = const PageScrollPhysics().applyTo(const ClampingScrollPhysics());
759

760
class _TabBarViewState extends State<TabBarView> {
Hans Muller's avatar
Hans Muller committed
761
  TabController _controller;
762
  PageController _pageController;
Hans Muller's avatar
Hans Muller committed
763 764 765 766 767
  List<Widget> _children;
  int _currentIndex;
  int _warpUnderwayCount = 0;

  void _updateTabController() {
768
    final TabController newController = widget.controller ?? DefaultTabController.of(context);
769 770 771
    assert(() {
      if (newController == null) {
        throw new FlutterError(
772 773
          'No TabController for ${widget.runtimeType}.\n'
          'When creating a ${widget.runtimeType}, you must either provide an explicit '
774
          'TabController using the "controller" property, or you must ensure that there '
775
          'is a DefaultTabController above the ${widget.runtimeType}.\n'
776 777 778 779 780
          'In this case, there was neither an explicit controller nor a default controller.'
        );
      }
      return true;
    });
Hans Muller's avatar
Hans Muller committed
781 782 783 784
    if (newController == _controller)
      return;

    if (_controller != null)
785
      _controller.animation.removeListener(_handleTabControllerAnimationTick);
Hans Muller's avatar
Hans Muller committed
786 787
    _controller = newController;
    if (_controller != null)
788
      _controller.animation.addListener(_handleTabControllerAnimationTick);
789 790
  }

Hans Muller's avatar
Hans Muller committed
791 792 793
  @override
  void initState() {
    super.initState();
794
    _children = widget.children;
Hans Muller's avatar
Hans Muller committed
795 796
  }

Hans Muller's avatar
Hans Muller committed
797
  @override
798 799
  void didChangeDependencies() {
    super.didChangeDependencies();
Hans Muller's avatar
Hans Muller committed
800 801
    _updateTabController();
    _currentIndex = _controller?.index;
802
    _pageController = new PageController(initialPage: _currentIndex ?? 0);
803 804
  }

805
  @override
806 807 808
  void didUpdateWidget(TabBarView oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.controller != oldWidget.controller)
Hans Muller's avatar
Hans Muller committed
809
      _updateTabController();
810 811
    if (widget.children != oldWidget.children && _warpUnderwayCount == 0)
      _children = widget.children;
812 813
  }

814
  @override
Hans Muller's avatar
Hans Muller committed
815 816
  void dispose() {
    if (_controller != null)
817
      _controller.animation.removeListener(_handleTabControllerAnimationTick);
Hans Muller's avatar
Hans Muller committed
818 819 820
    // We don't own the _controller Animation, so it's not disposed here.
    super.dispose();
  }
Hans Muller's avatar
Hans Muller committed
821

822 823
  void _handleTabControllerAnimationTick() {
    if (_warpUnderwayCount > 0 || !_controller.indexIsChanging)
Hans Muller's avatar
Hans Muller committed
824
      return; // This widget is driving the controller's animation.
Hans Muller's avatar
Hans Muller committed
825

Hans Muller's avatar
Hans Muller committed
826 827 828
    if (_controller.index != _currentIndex) {
      _currentIndex = _controller.index;
      _warpToCurrentIndex();
829
    }
Hans Muller's avatar
Hans Muller committed
830
  }
831

Hans Muller's avatar
Hans Muller committed
832 833 834
  Future<Null> _warpToCurrentIndex() async {
    if (!mounted)
      return new Future<Null>.value();
835

836
    if (_pageController.page == _currentIndex.toDouble())
Hans Muller's avatar
Hans Muller committed
837
      return new Future<Null>.value();
838

Hans Muller's avatar
Hans Muller committed
839 840
    final int previousIndex = _controller.previousIndex;
    if ((_currentIndex - previousIndex).abs() == 1)
841
      return _pageController.animateToPage(_currentIndex, duration: kTabScrollDuration, curve: Curves.ease);
Hans Muller's avatar
Hans Muller committed
842 843

    assert((_currentIndex - previousIndex).abs() > 1);
844
    int initialPage;
Hans Muller's avatar
Hans Muller committed
845 846
    setState(() {
      _warpUnderwayCount += 1;
847
      _children = new List<Widget>.from(widget.children, growable: false);
Hans Muller's avatar
Hans Muller committed
848 849
      if (_currentIndex > previousIndex) {
        _children[_currentIndex - 1] = _children[previousIndex];
850
        initialPage = _currentIndex - 1;
Hans Muller's avatar
Hans Muller committed
851 852
      } else {
        _children[_currentIndex + 1] = _children[previousIndex];
853
        initialPage = _currentIndex + 1;
Hans Muller's avatar
Hans Muller committed
854 855
      }
    });
856

857
    _pageController.jumpToPage(initialPage);
Hans Muller's avatar
Hans Muller committed
858

859
    await _pageController.animateToPage(_currentIndex, duration: kTabScrollDuration, curve: Curves.ease);
Hans Muller's avatar
Hans Muller committed
860 861
    if (!mounted)
      return new Future<Null>.value();
Hans Muller's avatar
Hans Muller committed
862

Hans Muller's avatar
Hans Muller committed
863 864
    setState(() {
      _warpUnderwayCount -= 1;
865
      _children = widget.children;
Hans Muller's avatar
Hans Muller committed
866
    });
Hans Muller's avatar
Hans Muller committed
867 868
  }

869
  // Called when the PageView scrolls
Adam Barth's avatar
Adam Barth committed
870
  bool _handleScrollNotification(ScrollNotification notification) {
Hans Muller's avatar
Hans Muller committed
871 872
    if (_warpUnderwayCount > 0)
      return false;
Hans Muller's avatar
Hans Muller committed
873

874
    if (notification.depth != 0)
Hans Muller's avatar
Hans Muller committed
875
      return false;
Hans Muller's avatar
Hans Muller committed
876

877
    _warpUnderwayCount += 1;
878
    if (notification is ScrollUpdateNotification && !_controller.indexIsChanging) {
879 880 881 882
      if ((_pageController.page - _controller.index).abs() > 1.0) {
        _controller.index = _pageController.page.floor();
        _currentIndex=_controller.index;
      }
883
      _controller.offset = (_pageController.page - _controller.index).clamp(-1.0, 1.0);
884
    } else if (notification is ScrollEndNotification) {
885 886 887 888
      final ScrollPosition position = _pageController.position;
      final double pageTolerance = position.physics.tolerance.distance
          / (position.viewportDimension * _pageController.viewportFraction);
      _controller.index = (_pageController.page + pageTolerance).floor();
889
      _currentIndex = _controller.index;
Hans Muller's avatar
Hans Muller committed
890
    }
891
    _warpUnderwayCount -= 1;
Hans Muller's avatar
Hans Muller committed
892 893

    return false;
Hans Muller's avatar
Hans Muller committed
894 895
  }

896
  @override
Hans Muller's avatar
Hans Muller committed
897
  Widget build(BuildContext context) {
Adam Barth's avatar
Adam Barth committed
898
    return new NotificationListener<ScrollNotification>(
Hans Muller's avatar
Hans Muller committed
899
      onNotification: _handleScrollNotification,
900 901 902
      child: new PageView(
        controller: _pageController,
        physics: _kTabBarViewPhysics,
Hans Muller's avatar
Hans Muller committed
903 904
        children: _children,
      ),
905
    );
Hans Muller's avatar
Hans Muller committed
906
  }
907
}
Hixie's avatar
Hixie committed
908

909 910 911 912
/// Displays a single 12x12 circle with the specified border and background colors.
///
/// Used by [TabPageSelector] to indicate the selected page.
class TabPageSelectorIndicator extends StatelessWidget {
913
  /// Creates an indicator used by [TabPageSelector].
914 915 916 917 918 919 920 921 922 923 924 925 926 927 928
  const TabPageSelectorIndicator({ Key key, this.backgroundColor, this.borderColor }) : super(key: key);

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

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

  @override
  Widget build(BuildContext context) {
    return new Container(
      width: 12.0,
      height: 12.0,
      margin: const EdgeInsets.all(4.0),
      decoration: new BoxDecoration(
929
        color: backgroundColor,
930
        border: new Border.all(color: borderColor),
931
        shape: BoxShape.circle,
932 933 934 935 936
      ),
    );
  }
}

Hans Muller's avatar
Hans Muller committed
937 938
/// Displays a row of small circular indicators, one per tab. The selected
/// tab's indicator is highlighted. Often used in conjuction with a [TabBarView].
939
///
Hans Muller's avatar
Hans Muller committed
940 941 942 943
/// If a [TabController] is not provided, then there must be a [DefaultTabController]
/// ancestor.
class TabPageSelector extends StatelessWidget {
  /// Creates a compact widget that indicates which tab has been selected.
944
  const TabPageSelector({ Key key, this.controller }) : super(key: key);
Hixie's avatar
Hixie committed
945

Hans Muller's avatar
Hans Muller committed
946 947 948 949 950 951 952 953 954 955 956 957
  /// This widget's selection and animation state.
  ///
  /// If [TabController] is not provided, then the value of [DefaultTabController.of]
  /// will be used.
  final TabController controller;

  Widget _buildTabIndicator(
    int tabIndex,
    TabController tabController,
    ColorTween selectedColor,
    ColorTween previousColor,
  ) {
Hixie's avatar
Hixie committed
958
    Color background;
Hans Muller's avatar
Hans Muller committed
959
    if (tabController.indexIsChanging) {
Hixie's avatar
Hixie committed
960
      // The selection's animation is animating from previousValue to value.
961
      final double t = 1.0 - _indexChangeProgress(tabController);
Hans Muller's avatar
Hans Muller committed
962
      if (tabController.index == tabIndex)
963
        background = selectedColor.lerp(t);
Hans Muller's avatar
Hans Muller committed
964
      else if (tabController.previousIndex == tabIndex)
965
        background = previousColor.lerp(t);
Hixie's avatar
Hixie committed
966 967 968
      else
        background = selectedColor.begin;
    } else {
969 970 971 972 973 974 975 976 977 978 979 980
      // The selection's offset reflects how far the TabBarView has
      /// been dragged to the left (-1.0 to 0.0) or the right (0.0 to 1.0).
      final double offset = tabController.offset;
      if (tabController.index == tabIndex) {
        background = selectedColor.lerp(1.0 - offset.abs());
      } else if (tabController.index == tabIndex - 1 && offset > 0.0) {
        background = selectedColor.lerp(offset);
      } else if (tabController.index == tabIndex + 1 && offset < 0.0) {
        background = selectedColor.lerp(-offset);
      } else {
        background = selectedColor.begin;
      }
Hixie's avatar
Hixie committed
981
    }
982 983 984
    return new TabPageSelectorIndicator(
      backgroundColor: background,
      borderColor: selectedColor.end,
Hixie's avatar
Hixie committed
985 986 987
    );
  }

988
  @override
Hixie's avatar
Hixie committed
989
  Widget build(BuildContext context) {
990
    final Color color = Theme.of(context).accentColor;
Hixie's avatar
Hixie committed
991 992
    final ColorTween selectedColor = new ColorTween(begin: Colors.transparent, end: color);
    final ColorTween previousColor = new ColorTween(begin: color, end: Colors.transparent);
Hans Muller's avatar
Hans Muller committed
993
    final TabController tabController = controller ?? DefaultTabController.of(context);
994 995 996 997 998 999 1000 1001 1002 1003 1004 1005
    assert(() {
      if (tabController == null) {
        throw new FlutterError(
          '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'
          'In this case, there was neither an explicit controller nor a default controller.'
        );
      }
      return true;
    });
Hans Muller's avatar
Hans Muller committed
1006 1007 1008 1009
    final Animation<double> animation = new CurvedAnimation(
      parent: tabController.animation,
      curve: Curves.fastOutSlowIn,
    );
Hixie's avatar
Hixie committed
1010 1011 1012 1013
    return new AnimatedBuilder(
      animation: animation,
      builder: (BuildContext context, Widget child) {
        return new Semantics(
1014
          label: 'Page ${tabController.index + 1} of ${tabController.length}',
Hixie's avatar
Hixie committed
1015
          child: new Row(
Hans Muller's avatar
Hans Muller committed
1016
            mainAxisSize: MainAxisSize.min,
1017 1018
            children: new List<Widget>.generate(tabController.length, (int tabIndex) {
              return _buildTabIndicator(tabIndex, tabController, selectedColor, previousColor);
Hans Muller's avatar
Hans Muller committed
1019 1020
            }).toList(),
          ),
Hixie's avatar
Hixie committed
1021 1022 1023 1024 1025
        );
      }
    );
  }
}