tabs.dart 32.4 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';
6 7
import 'dart:math' as math;

8
import 'package:newton/newton.dart';
9 10
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
11 12 13

import 'colors.dart';
import 'icon.dart';
Adam Barth's avatar
Adam Barth committed
14 15
import 'icon_theme.dart';
import 'icon_theme_data.dart';
16
import 'ink_well.dart';
17
import 'material.dart';
18
import 'theme.dart';
19

20 21
typedef void TabSelectedIndexChanged(int selectedIndex);
typedef void TabLayoutChanged(Size size, List<double> widths);
22 23 24 25 26 27 28 29

// See https://www.google.com/design/spec/components/tabs.html#tabs-specs
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;
const EdgeDims _kTabLabelPadding = const EdgeDims.symmetric(horizontal: 12.0);
30
const double _kTabBarScrollDrag = 0.025;
31
const Duration _kTabBarScroll = const Duration(milliseconds: 300);
32

33 34 35 36
// The scrollOffset (velocity) provided to fling() is pixels/ms, and the
// tolerance velocity is pixels/sec.
final double _kMinFlingVelocity = kPixelScrollTolerance.velocity / 2000.0;

Hixie's avatar
Hixie committed
37
class _TabBarParentData extends ContainerBoxParentDataMixin<RenderBox> { }
38

39 40 41
class _RenderTabBar extends RenderBox with
    ContainerRenderObjectMixin<RenderBox, _TabBarParentData>,
    RenderBoxContainerDefaultsMixin<RenderBox, _TabBarParentData> {
42

43
  _RenderTabBar(this.onLayoutChanged);
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62

  int _selectedIndex;
  int get selectedIndex => _selectedIndex;
  void set selectedIndex(int value) {
    if (_selectedIndex != value) {
      _selectedIndex = value;
      markNeedsPaint();
    }
  }

  Color _indicatorColor;
  Color get indicatorColor => _indicatorColor;
  void set indicatorColor(Color value) {
    if (_indicatorColor != value) {
      _indicatorColor = value;
      markNeedsPaint();
    }
  }

63 64 65 66 67 68 69 70 71
  Rect _indicatorRect;
  Rect get indicatorRect => _indicatorRect;
  void set indicatorRect(Rect value) {
    if (_indicatorRect != value) {
      _indicatorRect = value;
      markNeedsPaint();
    }
  }

72 73 74 75 76 77 78 79 80
  bool _textAndIcons;
  bool get textAndIcons => _textAndIcons;
  void set textAndIcons(bool value) {
    if (_textAndIcons != value) {
      _textAndIcons = value;
      markNeedsLayout();
    }
  }

81 82 83 84 85
  bool _isScrollable;
  bool get isScrollable => _isScrollable;
  void set isScrollable(bool value) {
    if (_isScrollable != value) {
      _isScrollable = value;
86 87 88 89 90
      markNeedsLayout();
    }
  }

  void setupParentData(RenderBox child) {
91 92
    if (child.parentData is! _TabBarParentData)
      child.parentData = new _TabBarParentData();
93 94 95 96 97 98 99 100 101 102
  }

  double getMinIntrinsicWidth(BoxConstraints constraints) {
    BoxConstraints widthConstraints =
        new BoxConstraints(maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight);

    double maxWidth = 0.0;
    RenderBox child = firstChild;
    while (child != null) {
      maxWidth = math.max(maxWidth, child.getMinIntrinsicWidth(widthConstraints));
Hixie's avatar
Hixie committed
103 104
      final _TabBarParentData childParentData = child.parentData;
      child = childParentData.nextSibling;
105
    }
106
    double width = isScrollable ? maxWidth : maxWidth * childCount;
107 108 109 110 111 112 113 114 115 116 117
    return constraints.constrainWidth(width);
  }

  double getMaxIntrinsicWidth(BoxConstraints constraints) {
    BoxConstraints widthConstraints =
        new BoxConstraints(maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight);

    double maxWidth = 0.0;
    RenderBox child = firstChild;
    while (child != null) {
      maxWidth = math.max(maxWidth, child.getMaxIntrinsicWidth(widthConstraints));
Hixie's avatar
Hixie committed
118 119
      final _TabBarParentData childParentData = child.parentData;
      child = childParentData.nextSibling;
120
    }
121
    double width = isScrollable ? maxWidth : maxWidth * childCount;
122 123 124
    return constraints.constrainWidth(width);
  }

Hans Muller's avatar
Hans Muller committed
125 126
  double get _tabHeight => textAndIcons ? _kTextAndIconTabHeight : _kTabHeight;
  double get _tabBarHeight => _tabHeight + _kTabIndicatorHeight;
127 128 129 130 131 132 133 134 135 136

  double _getIntrinsicHeight(BoxConstraints constraints) => constraints.constrainHeight(_tabBarHeight);

  double getMinIntrinsicHeight(BoxConstraints constraints) => _getIntrinsicHeight(constraints);

  double getMaxIntrinsicHeight(BoxConstraints constraints) => _getIntrinsicHeight(constraints);

  void layoutFixedWidthTabs() {
    double tabWidth = size.width / childCount;
    BoxConstraints tabConstraints =
Hans Muller's avatar
Hans Muller committed
137
      new BoxConstraints.tightFor(width: tabWidth, height: _tabHeight);
138 139 140 141
    double x = 0.0;
    RenderBox child = firstChild;
    while (child != null) {
      child.layout(tabConstraints);
Hixie's avatar
Hixie committed
142
      final _TabBarParentData childParentData = child.parentData;
143
      childParentData.offset = new Offset(x, 0.0);
144
      x += tabWidth;
Hixie's avatar
Hixie committed
145
      child = childParentData.nextSibling;
146 147 148
    }
  }

Hans Muller's avatar
Hans Muller committed
149
  double layoutScrollableTabs() {
150 151
    BoxConstraints tabConstraints = new BoxConstraints(
      minWidth: _kMinTabWidth,
Hans Muller's avatar
Hans Muller committed
152 153 154 155
      maxWidth: _kMaxTabWidth,
      minHeight: _tabHeight,
      maxHeight: _tabHeight
    );
156 157 158 159
    double x = 0.0;
    RenderBox child = firstChild;
    while (child != null) {
      child.layout(tabConstraints, parentUsesSize: true);
Hixie's avatar
Hixie committed
160
      final _TabBarParentData childParentData = child.parentData;
161
      childParentData.offset = new Offset(x, 0.0);
162
      x += child.size.width;
Hixie's avatar
Hixie committed
163
      child = childParentData.nextSibling;
164
    }
Hans Muller's avatar
Hans Muller committed
165
    return x;
166 167 168 169
  }

  Size layoutSize;
  List<double> layoutWidths;
170
  TabLayoutChanged onLayoutChanged;
171 172 173 174

  void reportLayoutChangedIfNeeded() {
    assert(onLayoutChanged != null);
    List<double> widths = new List<double>(childCount);
175
    if (!isScrollable && childCount > 0) {
176
      double tabWidth = size.width / childCount;
177
      widths.fillRange(0, widths.length, tabWidth);
178
    } else if (isScrollable) {
179 180 181 182
      RenderBox child = firstChild;
      int childIndex = 0;
      while (child != null) {
        widths[childIndex++] = child.size.width;
Hixie's avatar
Hixie committed
183 184
        final _TabBarParentData childParentData = child.parentData;
        child = childParentData.nextSibling;
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
      }
      assert(childIndex == widths.length);
    }
    if (size != layoutSize || widths != layoutWidths) {
      layoutSize = size;
      layoutWidths = widths;
      onLayoutChanged(layoutSize, layoutWidths);
    }
  }

  void performLayout() {
    assert(constraints is BoxConstraints);
    if (childCount == 0)
      return;

Hans Muller's avatar
Hans Muller committed
200 201 202 203 204
    if (isScrollable) {
      double tabBarWidth = layoutScrollableTabs();
      size = constraints.constrain(new Size(tabBarWidth, _tabBarHeight));
    } else {
      size = constraints.constrain(new Size(constraints.maxWidth, _tabBarHeight));
205
      layoutFixedWidthTabs();
Hans Muller's avatar
Hans Muller committed
206
    }
207 208 209 210 211

    if (onLayoutChanged != null)
      reportLayoutChangedIfNeeded();
  }

Adam Barth's avatar
Adam Barth committed
212 213
  bool hitTestChildren(HitTestResult result, { Point position }) {
    return defaultHitTestChildren(result, position: position);
214 215
  }

Adam Barth's avatar
Adam Barth committed
216
  void _paintIndicator(Canvas canvas, RenderBox selectedTab, Offset offset) {
217 218 219
    if (indicatorColor == null)
      return;

220
    if (indicatorRect != null) {
Hans Muller's avatar
Hans Muller committed
221
      canvas.drawRect(indicatorRect.shift(offset), new Paint()..color = indicatorColor);
222 223 224
      return;
    }

Hixie's avatar
Hixie committed
225 226 227
    final Size size = new Size(selectedTab.size.width, _kTabIndicatorHeight);
    final _TabBarParentData selectedTabParentData = selectedTab.parentData;
    final Point point = new Point(
228
      selectedTabParentData.offset.dx,
229 230
      _tabBarHeight - _kTabIndicatorHeight
    );
Hixie's avatar
Hixie committed
231
    canvas.drawRect((point + offset) & size, new Paint()..color = indicatorColor);
232 233
  }

234
  void paint(PaintingContext context, Offset offset) {
235 236 237
    int index = 0;
    RenderBox child = firstChild;
    while (child != null) {
Hixie's avatar
Hixie committed
238
      final _TabBarParentData childParentData = child.parentData;
Adam Barth's avatar
Adam Barth committed
239
      context.paintChild(child, childParentData.offset + offset);
240
      if (index++ == selectedIndex)
241
        _paintIndicator(context.canvas, child, offset);
Hixie's avatar
Hixie committed
242
      child = childParentData.nextSibling;
243 244 245 246
    }
  }
}

247 248
class _TabBarWrapper extends MultiChildRenderObjectWidget {
  _TabBarWrapper({
249
    Key key,
250 251 252
    List<Widget> children,
    this.selectedIndex,
    this.indicatorColor,
253
    this.indicatorRect,
254
    this.textAndIcons,
255
    this.isScrollable: false,
256
    this.onLayoutChanged
257 258 259 260
  }) : super(key: key, children: children);

  final int selectedIndex;
  final Color indicatorColor;
261
  final Rect indicatorRect;
262
  final bool textAndIcons;
263
  final bool isScrollable;
264
  final TabLayoutChanged onLayoutChanged;
265

266 267
  _RenderTabBar createRenderObject() {
    _RenderTabBar result = new _RenderTabBar(onLayoutChanged);
268 269 270
    updateRenderObject(result, null);
    return result;
  }
271

272
  void updateRenderObject(_RenderTabBar renderObject, _TabBarWrapper oldWidget) {
273 274 275 276 277 278
    renderObject.selectedIndex = selectedIndex;
    renderObject.indicatorColor = indicatorColor;
    renderObject.indicatorRect = indicatorRect;
    renderObject.textAndIcons = textAndIcons;
    renderObject.isScrollable = isScrollable;
    renderObject.onLayoutChanged = onLayoutChanged;
279 280 281
  }
}

282 283 284 285 286 287 288
typedef Widget TabLabelIconBuilder(BuildContext context, Color color);

/// Each TabBar tab can display either a title [text], an icon, or both. An icon
/// can be specified by either the icon or iconBuilder parameters. In either case
/// the icon will occupy a 24x24 box above the title text. If iconBuilder is
/// specified its color parameter is the color that an ordinary icon would have
/// been drawn with. The color reflects that tab's selection state.
289
class TabLabel {
290
  const TabLabel({ this.text, this.icon, this.iconBuilder });
291 292 293

  final String text;
  final String icon;
294
  final TabLabelIconBuilder iconBuilder;
295 296
}

297 298
class _Tab extends StatelessComponent {
  _Tab({
299
    Key key,
300
    this.onSelected,
301
    this.label,
302
    this.color
303
  }) : super(key: key) {
304
    assert(label.text != null || label.icon != null || label.iconBuilder != null);
305 306
  }

307
  final VoidCallback onSelected;
308
  final TabLabel label;
Hans Muller's avatar
Hans Muller committed
309
  final Color color;
310 311 312

  Widget _buildLabelText() {
    assert(label.text != null);
313
    TextStyle style = new TextStyle(color: color);
Hans Muller's avatar
Hans Muller committed
314
    return new Text(label.text, style: style);
315 316
  }

317 318 319 320 321 322 323 324 325 326 327
  Widget _buildLabelIcon(BuildContext context) {
    assert(label.icon != null || label.iconBuilder != null);
    if (label.icon != null) {
      return new Icon(icon: label.icon, color: color);
    } else {
      return new SizedBox(
        width: 24.0,
        height: 24.0,
        child: label.iconBuilder(context, color)
      );
    }
328 329
  }

330
  Widget build(BuildContext context) {
331
    Widget labelContent;
332
    if (label.icon == null && label.iconBuilder == null) {
333
      labelContent = _buildLabelText();
334
    } else if (label.text == null) {
335
      labelContent = _buildLabelIcon(context);
336
    } else {
337
      labelContent = new Column(
338
        children: <Widget>[
339
          new Container(
340
            child: _buildLabelIcon(context),
341 342 343 344 345
            margin: const EdgeDims.only(bottom: 10.0)
          ),
          _buildLabelText()
        ],
        justifyContent: FlexJustifyContent.center,
346
        alignItems: FlexAlignItems.center
347 348 349 350
      );
    }

    Container centeredLabel = new Container(
351
      child: new Center(child: labelContent, widthFactor: 1.0, heightFactor: 1.0),
352 353 354 355
      constraints: new BoxConstraints(minWidth: _kMinTabWidth),
      padding: _kTabLabelPadding
    );

356 357 358 359
    return new InkWell(
      onTap: onSelected,
      child: centeredLabel
    );
360
  }
Hixie's avatar
Hixie committed
361 362 363 364 365

  void debugFillDescription(List<String> description) {
    super.debugFillDescription(description);
    description.add('$label');
  }
366 367
}

368
class _TabsScrollBehavior extends BoundedBehavior {
369
  _TabsScrollBehavior();
370 371 372

  bool isScrollable = true;

373
  Simulation createFlingScrollSimulation(double position, double velocity) {
374 375 376 377 378 379 380 381 382 383 384 385 386 387
    if (!isScrollable)
      return null;

    double velocityPerSecond = velocity * 1000.0;
    return new BoundedFrictionSimulation(
      _kTabBarScrollDrag, position, velocityPerSecond, minScrollOffset, maxScrollOffset
    );
  }

  double applyCurve(double scrollOffset, double scrollDelta) {
    return (isScrollable) ? super.applyCurve(scrollOffset, scrollDelta) : 0.0;
  }
}

388
abstract class TabBarSelectionAnimationListener {
389
  void handleStatusChange(AnimationStatus status);
390 391
  void handleProgressChange();
  void handleSelectionDeactivate();
Hans Muller's avatar
Hans Muller committed
392 393
}

394
class TabBarSelection<T> extends StatefulComponent {
Hans Muller's avatar
Hans Muller committed
395 396
  TabBarSelection({
    Key key,
397 398
    this.value,
    this.values,
Hans Muller's avatar
Hans Muller committed
399
    this.onChanged,
400 401
    this.child
  }) : super(key: key)  {
402 403 404
    assert(values != null && values.length > 0);
    assert(new Set<T>.from(values).length == values.length);
    assert(value == null ? true : values.where((T e) => e == value).length == 1);
405
    assert(child != null);
406
  }
407

408 409 410
  final T value;
  List<T> values;
  final ValueChanged<T> onChanged;
Hans Muller's avatar
Hans Muller committed
411 412
  final Widget child;

Hixie's avatar
Hixie committed
413
  TabBarSelectionState<T> createState() => new TabBarSelectionState<T>();
Hans Muller's avatar
Hans Muller committed
414 415

  static TabBarSelectionState of(BuildContext context) {
416
    return context.ancestorStateOfType(const TypeMatcher<TabBarSelectionState>());
Hans Muller's avatar
Hans Muller committed
417 418 419
  }
}

420
class TabBarSelectionState<T> extends State<TabBarSelection<T>> {
421

422
  Animation<double> get animation => _controller.view;
423
  // Both the TabBar and TabBarView classes access _controller because they
Hans Muller's avatar
Hans Muller committed
424
  // alternately drive selection progress between tabs.
425
  final AnimationController _controller = new AnimationController(duration: _kTabBarScroll, value: 1.0);
426 427 428 429 430 431 432 433
  final Map<T, int> _valueToIndex = new Map<T, int>();

  void _initValueToIndex() {
    _valueToIndex.clear();
    int index = 0;
    for(T value in values)
      _valueToIndex[value] = index++;
  }
434

435 436
  void initState() {
    super.initState();
437
    _value = config.value ?? PageStorage.of(context)?.readState(context) ?? values.first;
438 439 440 441 442 443

    // If the selection's values have changed since the selected value was saved with
    // PageStorage.writeState() then use the default.
    if (!values.contains(_value))
      _value = values.first;

444 445 446 447 448 449 450 451
    _previousValue = _value;
    _initValueToIndex();
  }

  void didUpdateConfig(TabBarSelection oldConfig) {
    super.didUpdateConfig(oldConfig);
    if (values != oldConfig.values)
      _initValueToIndex();
452 453
  }

Hans Muller's avatar
Hans Muller committed
454
  void dispose() {
455
    _controller.stop();
456
    PageStorage.of(context)?.writeState(context, _value);
Hans Muller's avatar
Hans Muller committed
457 458 459
    super.dispose();
  }

460 461 462 463 464 465 466
  List<T> get values => config.values;

  T get previousValue => _previousValue;
  T _previousValue;

  bool _valueIsChanging = false;
  bool get valueIsChanging => _valueIsChanging;
Hans Muller's avatar
Hans Muller committed
467

468 469 470 471 472 473 474 475
  int indexOf(T tabValue) => _valueToIndex[tabValue];
  int get index => _valueToIndex[value];
  int get previousIndex => indexOf(_previousValue);

  T get value => _value;
  T _value;
  void set value(T newValue) {
    if (newValue == _value)
476
      return;
477 478 479 480
    if (!_valueIsChanging)
      _previousValue = _value;
    _value = newValue;
    _valueIsChanging = true;
481

482
    // If the selected value change was triggered by a drag gesture, the current
483
    // value of _controller.value will reflect where the gesture ended. While
484 485 486 487
    // the drag was underway progress indicates where the indicator and TabBarView
    // scrollPosition are vis the indices of the two tabs adjacent to the selected
    // one. So 0.5 means the drag didn't move at all, 0.0 means the drag extended
    // to the beginning of the tab on the left and 1.0 likewise for the tab on the
488 489 490
    // right. That is unless the index of the selected value was 0 or values.length - 1.
    // In those cases progress just moves between the selected tab and the adjacent
    // one. Convert progress to reflect the fact that we're now moving between (just)
491 492
    // the previous and current selection index.

493
    double value;
494
    if (_controller.status == AnimationStatus.completed)
495
      value = 0.0;
496
    else if (_previousValue == values.first)
497
      value = _controller.value;
498
    else if (_previousValue == values.last)
499
      value = 1.0 - _controller.value;
500
    else if (previousIndex < index)
501
      value = (_controller.value - 0.5) * 2.0;
502
    else
503
      value = 1.0 - _controller.value * 2.0;
504

505
    _controller
506
      ..value = value
507
      ..forward().then((_) {
508
        if (_controller.value == 1.0) {
509
          if (config.onChanged != null)
510 511
            config.onChanged(_value);
          _valueIsChanging = false;
512
        }
513 514 515
      });
  }

516
  final List<TabBarSelectionAnimationListener> _animationListeners = <TabBarSelectionAnimationListener>[];
517

518 519
  void registerAnimationListener(TabBarSelectionAnimationListener listener) {
    _animationListeners.add(listener);
520
    _controller
521 522 523 524
      ..addStatusListener(listener.handleStatusChange)
      ..addListener(listener.handleProgressChange);
  }

525 526
  void unregisterAnimationListener(TabBarSelectionAnimationListener listener) {
    _animationListeners.remove(listener);
527
    _controller
528 529 530 531 532
      ..removeStatusListener(listener.handleStatusChange)
      ..removeListener(listener.handleProgressChange);
  }

  void deactivate() {
533
    for (TabBarSelectionAnimationListener listener in _animationListeners.toList()) {
534
      listener.handleSelectionDeactivate();
535
      unregisterAnimationListener(listener);
536
    }
537
    assert(_animationListeners.isEmpty);
538 539
  }

Hans Muller's avatar
Hans Muller committed
540
  Widget build(BuildContext context) {
541
    return config.child;
Hans Muller's avatar
Hans Muller committed
542
  }
543 544
}

Hans Muller's avatar
Hans Muller committed
545 546 547 548 549
/// Displays a horizontal row of tabs, one per label. 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. A [TabBarSelection] widget ancestor must have been
/// built to enable saving and monitoring the selected tab.
550 551
///
/// Tabs must always have an ancestor Material object.
552
class TabBar<T> extends Scrollable {
553
  TabBar({
554
    Key key,
555
    this.labels,
556
    this.isScrollable: false
557
  }) : super(key: key, scrollDirection: Axis.horizontal);
558

559
  final Map<T, TabLabel> labels;
560
  final bool isScrollable;
561

562
  _TabBarState createState() => new _TabBarState();
563
}
564

565
class _TabBarState<T> extends ScrollableState<TabBar<T>> implements TabBarSelectionAnimationListener {
Hans Muller's avatar
Hans Muller committed
566 567

  TabBarSelectionState _selection;
568
  bool _valueIsChanging = false;
Hans Muller's avatar
Hans Muller committed
569

570
  void _initSelection(TabBarSelectionState<T> selection) {
571
    _selection?.unregisterAnimationListener(this);
572
    _selection = selection;
573
    _selection?.registerAnimationListener(this);
574
  }
Hans Muller's avatar
Hans Muller committed
575

576 577
  void initState() {
    super.initState();
578
    scrollBehavior.isScrollable = config.isScrollable;
579 580 581 582 583 584 585
    _initSelection(TabBarSelection.of(context));
  }

  void didUpdateConfig(TabBar oldConfig) {
    super.didUpdateConfig(oldConfig);
    if (!config.isScrollable)
      scrollTo(0.0);
586 587 588
  }

  void dispose() {
589
    _selection?.unregisterAnimationListener(this);
590 591 592
    super.dispose();
  }

593 594 595 596
  void handleSelectionDeactivate() {
    _selection = null;
  }

597
  void handleStatusChange(AnimationStatus status) {
598
    if (config.labels.length == 0)
Hans Muller's avatar
Hans Muller committed
599 600
      return;

601
    if (_valueIsChanging && status == AnimationStatus.completed) {
602
      _valueIsChanging = false;
603 604 605
      _indicatorTween
        ..begin = _tabIndicatorRect(math.max(0, _selection.index - 1))
        ..end = _tabIndicatorRect(math.min(config.labels.length - 1, _selection.index + 1));
606
      setState(() {
607
        _indicatorRect = _tabIndicatorRect(_selection.index);
608 609
      });
    }
610
  }
611

612
  void handleProgressChange() {
613
    if (config.labels.length == 0 || _selection == null)
Hans Muller's avatar
Hans Muller committed
614 615
      return;

616
    if (!_valueIsChanging && _selection.valueIsChanging) {
Hans Muller's avatar
Hans Muller committed
617
      if (config.isScrollable)
Hans Muller's avatar
Hans Muller committed
618
        scrollTo(_centeredTabScrollOffset(_selection.index), duration: _kTabBarScroll);
619 620 621
      _indicatorTween
        ..begin = _indicatorRect ?? _tabIndicatorRect(_selection.previousIndex)
        ..end = _tabIndicatorRect(_selection.index);
622
      _valueIsChanging = true;
623
    }
624
    Rect oldRect = _indicatorRect;
625
    double t = _selection.animation.value;
626 627 628 629 630 631 632 633 634 635 636
    if (_valueIsChanging) {
      // When _valueIsChanging is true, we're animating based on a ticker and
      // want to curve the animation. When _valueIsChanging is false, we're
      // animating based on a pointer event and want linear feedback. It's
      // possible we should move this curve into the selection animation.
      t = Curves.ease.transform(t);
    }
    // TODO(abarth): If we've never gone through handleStatusChange before, we
    // might not have set up our _indicatorTween yet.
    _indicatorRect = _indicatorTween.lerp(t);
    if (oldRect != _indicatorRect)
637
      setState(() { });
638 639
  }

640 641 642
  Size _viewportSize = Size.zero;
  Size _tabBarSize;
  List<double> _tabWidths;
643 644
  Rect _indicatorRect;
  RectTween _indicatorTween = new RectTween();
645

646 647 648 649 650 651
  Rect _tabRect(int tabIndex) {
    assert(_tabBarSize != null);
    assert(_tabWidths != null);
    assert(tabIndex >= 0 && tabIndex < _tabWidths.length);
    double tabLeft = 0.0;
    if (tabIndex > 0)
Hixie's avatar
Hixie committed
652
      tabLeft = _tabWidths.take(tabIndex).reduce((double sum, double width) => sum + width);
Hans Muller's avatar
Hans Muller committed
653 654 655
    final double tabTop = 0.0;
    final double tabBottom = _tabBarSize.height - _kTabIndicatorHeight;
    final double tabRight = tabLeft + _tabWidths[tabIndex];
656 657 658 659 660 661 662 663
    return new Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom);
  }

  Rect _tabIndicatorRect(int tabIndex) {
    Rect r = _tabRect(tabIndex);
    return new Rect.fromLTRB(r.left, r.bottom, r.right, r.bottom + _kTabIndicatorHeight);
  }

664 665 666
  ScrollBehavior createScrollBehavior() => new _TabsScrollBehavior();
  _TabsScrollBehavior get scrollBehavior => super.scrollBehavior;

667
  double _centeredTabScrollOffset(int tabIndex) {
668
    double viewportWidth = scrollBehavior.containerExtent;
669 670
    Rect tabRect = _tabRect(tabIndex);
    return (tabRect.left + tabRect.width / 2.0 - viewportWidth / 2.0)
671 672 673
      .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset);
  }

674
  void _handleTabSelected(int tabIndex) {
Hans Muller's avatar
Hans Muller committed
675
    if (_selection != null && tabIndex != _selection.index)
676
      setState(() {
677
        _selection.value = _selection.values[tabIndex];
678
      });
679 680
  }

Hans Muller's avatar
Hans Muller committed
681
  Widget _toTab(TabLabel label, int tabIndex, Color color, Color selectedColor) {
Hans Muller's avatar
Hans Muller committed
682 683 684 685 686
    Color labelColor = color;
    if (_selection != null) {
      final bool isSelectedTab = tabIndex == _selection.index;
      final bool isPreviouslySelectedTab = tabIndex == _selection.previousIndex;
      labelColor = isSelectedTab ? selectedColor : color;
687
      if (_selection.valueIsChanging) {
Hans Muller's avatar
Hans Muller committed
688
        if (isSelectedTab)
689
          labelColor = Color.lerp(color, selectedColor, _selection.animation.value);
Hans Muller's avatar
Hans Muller committed
690
        else if (isPreviouslySelectedTab)
691
          labelColor = Color.lerp(selectedColor, color, _selection.animation.value);
Hans Muller's avatar
Hans Muller committed
692
      }
Hans Muller's avatar
Hans Muller committed
693
    }
694 695
    return new _Tab(
      onSelected: () { _handleTabSelected(tabIndex); },
696
      label: label,
697
      color: labelColor
698 699 700
    );
  }

Hans Muller's avatar
Hans Muller committed
701 702
  void _updateScrollBehavior() {
    scrollBehavior.updateExtents(
703
      containerExtent: config.scrollDirection == Axis.vertical ? _viewportSize.height : _viewportSize.width,
Hixie's avatar
Hixie committed
704
      contentExtent: _tabWidths.reduce((double sum, double width) => sum + width)
Hans Muller's avatar
Hans Muller committed
705 706 707
    );
  }

708 709 710 711
  void _layoutChanged(Size tabBarSize, List<double> tabWidths) {
    setState(() {
      _tabBarSize = tabBarSize;
      _tabWidths = tabWidths;
Hans Muller's avatar
Hans Muller committed
712
      _updateScrollBehavior();
713 714 715
    });
  }

Hans Muller's avatar
Hans Muller committed
716 717 718
  void _handleViewportSizeChanged(Size newSize) {
    _viewportSize = newSize;
    _updateScrollBehavior();
719
    if (config.isScrollable)
Hans Muller's avatar
Hans Muller committed
720
      scrollTo(_centeredTabScrollOffset(_selection.index), duration: _kTabBarScroll);
Hans Muller's avatar
Hans Muller committed
721 722
  }

723
  Widget buildContent(BuildContext context) {
724 725 726
    TabBarSelectionState<T> newSelection = TabBarSelection.of(context);
    if (_selection != newSelection)
      _initSelection(newSelection);
Hans Muller's avatar
Hans Muller committed
727

728
    assert(config.labels.isNotEmpty);
729
    assert(Material.of(context) != null);
730

731
    ThemeData themeData = Theme.of(context);
732
    Color backgroundColor = Material.of(context).color;
733 734 735 736 737 738 739 740 741
    Color indicatorColor = themeData.indicatorColor;
    if (indicatorColor == backgroundColor) {
      // ThemeData tries to avoid this by having indicatorColor avoid being the
      // primaryColor. However, it's possible that the tab strip 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.
742
      indicatorColor = Colors.white;
743
    }
744

Adam Barth's avatar
Adam Barth committed
745 746
    TextStyle textStyle = themeData.primaryTextTheme.body1;
    IconThemeData iconTheme = themeData.primaryIconTheme;
747
    Color textColor = themeData.primaryTextTheme.body1.color.withAlpha(0xB2); // 70% alpha
748

Hans Muller's avatar
Hans Muller committed
749 750 751
    List<Widget> tabs = <Widget>[];
    bool textAndIcons = false;
    int tabIndex = 0;
752
    for (TabLabel label in config.labels.values) {
753 754
      tabs.add(_toTab(label, tabIndex++, textColor, indicatorColor));
      if (label.text != null && (label.icon != null || label.iconBuilder != null))
Hans Muller's avatar
Hans Muller committed
755 756 757
        textAndIcons = true;
    }

758
    Widget contents = new IconTheme(
Adam Barth's avatar
Adam Barth committed
759
      data: iconTheme,
Hans Muller's avatar
Hans Muller committed
760 761
      child: new DefaultTextStyle(
        style: textStyle,
Hans Muller's avatar
Hans Muller committed
762 763
        child: new _TabBarWrapper(
          children: tabs,
Hans Muller's avatar
Hans Muller committed
764
          selectedIndex: _selection?.index,
Hans Muller's avatar
Hans Muller committed
765
          indicatorColor: indicatorColor,
766
          indicatorRect: _indicatorRect,
Hans Muller's avatar
Hans Muller committed
767 768 769
          textAndIcons: textAndIcons,
          isScrollable: config.isScrollable,
          onLayoutChanged: _layoutChanged
770 771 772
        )
      )
    );
Hans Muller's avatar
Hans Muller committed
773

774
    if (config.isScrollable) {
775
      contents = new SizeObserver(
776
        onSizeChanged: _handleViewportSizeChanged,
777
        child: new Viewport(
778
          scrollDirection: Axis.horizontal,
779
          paintOffset: scrollOffsetToPixelDelta(scrollOffset),
780
          child: contents
781 782 783 784
        )
      );
    }

785
    return contents;
786 787 788
  }
}

Adam Barth's avatar
Adam Barth committed
789
class TabBarView extends PageableList {
790 791
  TabBarView({
    Key key,
Adam Barth's avatar
Adam Barth committed
792 793
    List<Widget> children
  }) : super(
794
    key: key,
795
    scrollDirection: Axis.horizontal,
Adam Barth's avatar
Adam Barth committed
796
    children: children
797
  ) {
Adam Barth's avatar
Adam Barth committed
798 799
    assert(children != null);
    assert(children.length > 1);
800 801
  }

Adam Barth's avatar
Adam Barth committed
802
  _TabBarViewState createState() => new _TabBarViewState();
803 804
}

805
class _TabBarViewState extends PageableListState<TabBarView> implements TabBarSelectionAnimationListener {
Hans Muller's avatar
Hans Muller committed
806 807

  TabBarSelectionState _selection;
808
  List<Widget> _items;
809 810
  AnimationDirection _scrollDirection = AnimationDirection.forward;

Adam Barth's avatar
Adam Barth committed
811
  int get _tabCount => config.children.length;
Hans Muller's avatar
Hans Muller committed
812 813 814 815 816 817 818 819

  BoundedBehavior _boundedBehavior;

  ExtentScrollBehavior get scrollBehavior {
    _boundedBehavior ??= new BoundedBehavior();
    return _boundedBehavior;
  }

Adam Barth's avatar
Adam Barth committed
820
  void _initSelection(TabBarSelectionState selection) {
821
    _selection = selection;
822
    if (_selection != null) {
823
      _selection.registerAnimationListener(this);
824
      _updateItemsAndScrollBehavior();
825 826 827
    }
  }

828 829 830 831 832
  void initState() {
    super.initState();
    _initSelection(TabBarSelection.of(context));
  }

833 834 835 836 837 838
  void didUpdateConfig(TabBarView oldConfig) {
    super.didUpdateConfig(oldConfig);
    if (_selection != null && config.children != oldConfig.children)
      _updateItemsForSelectedIndex(_selection.index);
  }

839
  void dispose() {
840
    _selection?.unregisterAnimationListener(this);
841 842
    super.dispose();
  }
843

844 845 846 847
  void handleSelectionDeactivate() {
    _selection = null;
  }

848
  void _updateItemsFromChildren(int first, int second, [int third]) {
849 850 851 852 853 854
    List<Widget> widgets = config.children;
    _items = <Widget>[widgets[first], widgets[second]];
    if (third != null)
      _items.add(widgets[third]);
  }

855 856 857 858 859 860 861 862 863 864 865
  void _updateItemsForSelectedIndex(int selectedIndex) {
    if (selectedIndex == 0) {
      _updateItemsFromChildren(0, 1);
    } else if (selectedIndex == _tabCount - 1) {
      _updateItemsFromChildren(selectedIndex - 1, selectedIndex);
    } else {
      _updateItemsFromChildren(selectedIndex - 1, selectedIndex, selectedIndex + 1);
    }
  }

  void _updateScrollBehaviorForSelectedIndex(int selectedIndex) {
Hans Muller's avatar
Hans Muller committed
866
    if (selectedIndex == 0) {
Hans Muller's avatar
Hans Muller committed
867
      scrollTo(scrollBehavior.updateExtents(contentExtent: 2.0, containerExtent: 1.0, scrollOffset: 0.0));
Hans Muller's avatar
Hans Muller committed
868
    } else if (selectedIndex == _tabCount - 1) {
Hans Muller's avatar
Hans Muller committed
869
      scrollTo(scrollBehavior.updateExtents(contentExtent: 2.0, containerExtent: 1.0, scrollOffset: 1.0));
Hans Muller's avatar
Hans Muller committed
870
    } else {
Hans Muller's avatar
Hans Muller committed
871
      scrollTo(scrollBehavior.updateExtents(contentExtent: 3.0, containerExtent: 1.0, scrollOffset: 1.0));
Hans Muller's avatar
Hans Muller committed
872 873 874
    }
  }

875 876 877
  void _updateItemsAndScrollBehavior() {
    assert(_selection != null);
    final int selectedIndex = _selection.index;
878
    assert(selectedIndex != null);
879 880 881 882
    _updateItemsForSelectedIndex(selectedIndex);
    _updateScrollBehaviorForSelectedIndex(selectedIndex);
  }

883
  void handleStatusChange(AnimationStatus status) {
884 885 886
  }

  void handleProgressChange() {
887
    if (_selection == null || !_selection.valueIsChanging)
Hans Muller's avatar
Hans Muller committed
888
      return;
889
    // The TabBar is driving the TabBarSelection animation.
Hans Muller's avatar
Hans Muller committed
890

891
    final Animation<double> animation = _selection.animation;
Hans Muller's avatar
Hans Muller committed
892

893
    if (animation.status == AnimationStatus.completed) {
894
      _updateItemsAndScrollBehavior();
895
      return;
896 897
    }

898
    if (animation.status != AnimationStatus.forward)
Hans Muller's avatar
Hans Muller committed
899
      return;
900

Hans Muller's avatar
Hans Muller committed
901 902
    final int selectedIndex = _selection.index;
    final int previousSelectedIndex = _selection.previousIndex;
903 904

    if (selectedIndex < previousSelectedIndex) {
905
      _updateItemsFromChildren(selectedIndex, previousSelectedIndex);
906 907
      _scrollDirection = AnimationDirection.reverse;
    } else {
908
      _updateItemsFromChildren(previousSelectedIndex, selectedIndex);
909 910
      _scrollDirection = AnimationDirection.forward;
    }
Hans Muller's avatar
Hans Muller committed
911

912
    if (_scrollDirection == AnimationDirection.forward)
913
      scrollTo(animation.value);
914
    else
915
      scrollTo(1.0 - animation.value);
916 917
  }

Hans Muller's avatar
Hans Muller committed
918
  void dispatchOnScroll() {
919
    if (_selection == null || _selection.valueIsChanging)
Hans Muller's avatar
Hans Muller committed
920
      return;
921
    // This class is driving the TabBarSelection's animation.
Hans Muller's avatar
Hans Muller committed
922

923
    final AnimationController controller = _selection._controller;
Hans Muller's avatar
Hans Muller committed
924 925

    if (_selection.index == 0 || _selection.index == _tabCount - 1)
926
      controller.value = scrollOffset;
Hans Muller's avatar
Hans Muller committed
927
    else
928
      controller.value = scrollOffset / 2.0;
Hans Muller's avatar
Hans Muller committed
929 930
  }

931
  Future fling(double scrollVelocity) {
932
    if (_selection == null || _selection.valueIsChanging)
Hans Muller's avatar
Hans Muller committed
933 934
      return new Future.value();

935 936
    if (scrollVelocity.abs() > _kMinFlingVelocity) {
      final int selectionDelta = scrollVelocity.sign.truncate();
937 938
      final int targetIndex = (_selection.index + selectionDelta).clamp(0, _tabCount - 1);
      _selection.value = _selection.values[targetIndex];
Hans Muller's avatar
Hans Muller committed
939 940 941
      return new Future.value();
    }

Hans Muller's avatar
Hans Muller committed
942
    final int selectionIndex = _selection.index;
Hans Muller's avatar
Hans Muller committed
943 944
    final int settleIndex = snapScrollOffset(scrollOffset).toInt();
    if (selectionIndex > 0 && settleIndex != 1) {
945 946
      final int targetIndex = (selectionIndex + (settleIndex == 2 ? 1 : -1)).clamp(0, _tabCount - 1);
      _selection.value = _selection.values[targetIndex];
947
      return new Future.value();
Hans Muller's avatar
Hans Muller committed
948
    } else if (selectionIndex == 0 && settleIndex == 1) {
949
      _selection.value = _selection.values[1];
Hans Muller's avatar
Hans Muller committed
950 951 952 953 954
      return new Future.value();
    }
    return settleScrollOffset();
  }

955
  Widget buildContent(BuildContext context) {
Adam Barth's avatar
Adam Barth committed
956
    TabBarSelectionState newSelection = TabBarSelection.of(context);
957 958
    if (_selection != newSelection)
      _initSelection(newSelection);
959 960 961 962 963 964 965
    return new PageViewport(
      itemsWrap: config.itemsWrap,
      scrollDirection: config.scrollDirection,
      startOffset: scrollOffset,
      overlayPainter: config.scrollableListPainter,
      children: _items
    );
Hans Muller's avatar
Hans Muller committed
966
  }
967
}
Hixie's avatar
Hixie committed
968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016

class TabPageSelector<T> extends StatelessComponent {
  const TabPageSelector({ Key key }) : super(key: key);

  Widget _buildTabIndicator(TabBarSelectionState<T> selection, T tab, Animation animation, ColorTween selectedColor, ColorTween previousColor) {
    Color background;
    if (selection.valueIsChanging) {
      // The selection's animation is animating from previousValue to value.
      if (selection.value == tab)
        background = selectedColor.evaluate(animation);
      else if (selection.previousValue == tab)
        background = previousColor.evaluate(animation);
      else
        background = selectedColor.begin;
    } else {
      background = selection.value == tab ? selectedColor.end : selectedColor.begin;
    }
    return new Container(
      width: 12.0,
      height: 12.0,
      margin: new EdgeDims.all(4.0),
      decoration: new BoxDecoration(
        backgroundColor: background,
        border: new Border.all(color: selectedColor.end),
        shape: BoxShape.circle
      )
    );
  }

  Widget build(BuildContext context) {
    final TabBarSelectionState selection = TabBarSelection.of(context);
    final Color color = Theme.of(context).primaryColor;
    final ColorTween selectedColor = new ColorTween(begin: Colors.transparent, end: color);
    final ColorTween previousColor = new ColorTween(begin: color, end: Colors.transparent);
    Animation<double> animation = new CurvedAnimation(parent: selection.animation, curve: Curves.ease);
    return new AnimatedBuilder(
      animation: animation,
      builder: (BuildContext context, Widget child) {
        return new Semantics(
          label: 'Page ${selection.index + 1} of ${selection.values.length}',
          child: new Row(
            children: selection.values.map((T tab) => _buildTabIndicator(selection, tab, animation, selectedColor, previousColor)).toList(),
            justifyContent: FlexJustifyContent.collapse
          )
        );
      }
    );
  }
}