tabs.dart 18.5 KB
Newer Older
1 2 3 4 5
// 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.

import 'dart:math' as math;
Hans Muller's avatar
Hans Muller committed
6
import 'dart:sky' as sky;
7

8
import 'package:newton/newton.dart';
9 10 11
import 'package:sky/animation/animation_performance.dart';
import 'package:sky/animation/animated_value.dart';
import 'package:sky/animation/curves.dart';
12 13 14 15 16 17 18 19 20 21 22 23
import 'package:sky/animation/scroll_behavior.dart';
import 'package:sky/painting/text_style.dart';
import 'package:sky/rendering/box.dart';
import 'package:sky/rendering/object.dart';
import 'package:sky/theme/colors.dart' as colors;
import 'package:sky/theme/typography.dart' as typography;
import 'package:sky/widgets/basic.dart';
import 'package:sky/widgets/default_text_style.dart';
import 'package:sky/widgets/icon.dart';
import 'package:sky/widgets/ink_well.dart';
import 'package:sky/widgets/scrollable.dart';
import 'package:sky/widgets/theme.dart';
24
import 'package:sky/widgets/transitions.dart';
25
import 'package:sky/widgets/framework.dart';
26 27 28 29 30 31 32 33 34 35 36 37 38 39
import 'package:vector_math/vector_math.dart';

typedef void SelectedIndexChanged(int selectedIndex);
typedef void LayoutChanged(Size size, List<double> widths);

// 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 double _kRelativeMaxTabWidth = 56.0;
const EdgeDims _kTabLabelPadding = const EdgeDims.symmetric(horizontal: 12.0);
const int _kTabIconSize = 24;
40
const double _kTabBarScrollDrag = 0.025;
41
const Duration _kTabBarScroll = const Duration(milliseconds: 200);
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78

class TabBarParentData extends BoxParentData with
    ContainerParentDataMixin<RenderBox> { }

class RenderTabBar extends RenderBox with
    ContainerRenderObjectMixin<RenderBox, TabBarParentData>,
    RenderBoxContainerDefaultsMixin<RenderBox, TabBarParentData> {

  RenderTabBar(this.onLayoutChanged);

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

  Color _backgroundColor;
  Color get backgroundColor => _backgroundColor;
  void set backgroundColor(Color value) {
    if (_backgroundColor != value) {
      _backgroundColor = value;
      markNeedsPaint();
    }
  }

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

79 80 81 82 83 84 85 86 87
  Rect _indicatorRect;
  Rect get indicatorRect => _indicatorRect;
  void set indicatorRect(Rect value) {
    if (_indicatorRect != value) {
      _indicatorRect = value;
      markNeedsPaint();
    }
  }

88 89 90 91 92 93 94 95 96
  bool _textAndIcons;
  bool get textAndIcons => _textAndIcons;
  void set textAndIcons(bool value) {
    if (_textAndIcons != value) {
      _textAndIcons = value;
      markNeedsLayout();
    }
  }

97 98 99 100 101
  bool _isScrollable;
  bool get isScrollable => _isScrollable;
  void set isScrollable(bool value) {
    if (_isScrollable != value) {
      _isScrollable = value;
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121
      markNeedsLayout();
    }
  }

  void setupParentData(RenderBox child) {
    if (child.parentData is! TabBarParentData)
      child.parentData = new TabBarParentData();
  }

  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));
      assert(child.parentData is TabBarParentData);
      child = child.parentData.nextSibling;
    }
122
    double width = isScrollable ? maxWidth : maxWidth * childCount;
123 124 125 126 127 128 129 130 131 132 133 134 135 136
    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));
      assert(child.parentData is TabBarParentData);
      child = child.parentData.nextSibling;
    }
137
    double width = isScrollable ? maxWidth : maxWidth * childCount;
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189
    return constraints.constrainWidth(width);
  }

  double get _tabBarHeight {
    return (textAndIcons ? _kTextAndIconTabHeight : _kTabHeight) + _kTabIndicatorHeight;
  }

  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 =
      new BoxConstraints.tightFor(width: tabWidth, height: size.height);
    double x = 0.0;
    RenderBox child = firstChild;
    while (child != null) {
      child.layout(tabConstraints);
      assert(child.parentData is TabBarParentData);
      child.parentData.position = new Point(x, 0.0);
      x += tabWidth;
      child = child.parentData.nextSibling;
    }
  }

  void layoutScrollableTabs() {
    BoxConstraints tabConstraints = new BoxConstraints(
      minWidth: _kMinTabWidth,
      maxWidth: math.min(size.width - _kRelativeMaxTabWidth, _kMaxTabWidth),
      minHeight: size.height,
      maxHeight: size.height);
    double x = 0.0;
    RenderBox child = firstChild;
    while (child != null) {
      child.layout(tabConstraints, parentUsesSize: true);
      assert(child.parentData is TabBarParentData);
      child.parentData.position = new Point(x, 0.0);
      x += child.size.width;
      child = child.parentData.nextSibling;
    }
  }

  Size layoutSize;
  List<double> layoutWidths;
  LayoutChanged onLayoutChanged;

  void reportLayoutChangedIfNeeded() {
    assert(onLayoutChanged != null);
    List<double> widths = new List<double>(childCount);
190
    if (!isScrollable && childCount > 0) {
191
      double tabWidth = size.width / childCount;
192
      widths.fillRange(0, widths.length, tabWidth);
193
    } else if (isScrollable) {
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217
      RenderBox child = firstChild;
      int childIndex = 0;
      while (child != null) {
        widths[childIndex++] = child.size.width;
        child = child.parentData.nextSibling;
      }
      assert(childIndex == widths.length);
    }
    if (size != layoutSize || widths != layoutWidths) {
      layoutSize = size;
      layoutWidths = widths;
      onLayoutChanged(layoutSize, layoutWidths);
    }
  }

  void performLayout() {
    assert(constraints is BoxConstraints);

    size = constraints.constrain(new Size(constraints.maxWidth, _tabBarHeight));
    assert(!size.isInfinite);

    if (childCount == 0)
      return;

218
    if (isScrollable)
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234
      layoutScrollableTabs();
    else
      layoutFixedWidthTabs();

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

  void hitTestChildren(HitTestResult result, { Point position }) {
    defaultHitTestChildren(result, position: position);
  }

  void _paintIndicator(PaintingCanvas canvas, RenderBox selectedTab, Offset offset) {
    if (indicatorColor == null)
      return;

235 236 237 238 239
    if (indicatorRect != null) {
      canvas.drawRect(indicatorRect, new Paint()..color = indicatorColor);
      return;
    }

240 241 242 243 244 245 246 247 248
    var size = new Size(selectedTab.size.width, _kTabIndicatorHeight);
    var point = new Point(
      selectedTab.parentData.position.x,
      _tabBarHeight - _kTabIndicatorHeight
    );
    Rect rect = (point + offset) & size;
    canvas.drawRect(rect, new Paint()..color = indicatorColor);
  }

249
  void paint(PaintingContext context, Offset offset) {
250 251 252 253 254
    if (backgroundColor != null) {
      double width = layoutWidths != null
        ? layoutWidths.reduce((sum, width) => sum + width)
        : size.width;
      Rect rect = offset & new Size(width, size.height);
255
      context.canvas.drawRect(rect, new Paint()..color = backgroundColor);
256 257 258 259 260
    }
    int index = 0;
    RenderBox child = firstChild;
    while (child != null) {
      assert(child.parentData is TabBarParentData);
261
      context.paintChild(child, child.parentData.position + offset);
262
      if (index++ == selectedIndex)
263
        _paintIndicator(context.canvas, child, offset);
264 265 266 267 268 269 270
      child = child.parentData.nextSibling;
    }
  }
}

class TabBarWrapper extends MultiChildRenderObjectWrapper {
  TabBarWrapper({
271
    Key key,
272 273 274 275
    List<Widget> children,
    this.selectedIndex,
    this.backgroundColor,
    this.indicatorColor,
276
    this.indicatorRect,
277
    this.textAndIcons,
278
    this.isScrollable: false,
279
    this.onLayoutChanged
280 281 282 283 284
  }) : super(key: key, children: children);

  final int selectedIndex;
  final Color backgroundColor;
  final Color indicatorColor;
285
  final Rect indicatorRect;
286
  final bool textAndIcons;
287
  final bool isScrollable;
288 289 290 291 292 293 294 295 296 297
  final LayoutChanged onLayoutChanged;

  RenderTabBar get root => super.root;
  RenderTabBar createNode() => new RenderTabBar(onLayoutChanged);

  void syncRenderObject(Widget old) {
    super.syncRenderObject(old);
    root.selectedIndex = selectedIndex;
    root.backgroundColor = backgroundColor;
    root.indicatorColor = indicatorColor;
298
    root.indicatorRect = indicatorRect;
299
    root.textAndIcons = textAndIcons;
300
    root.isScrollable = isScrollable;
301 302 303 304 305 306 307 308 309 310 311 312 313
    root.onLayoutChanged = onLayoutChanged;
  }
}

class TabLabel {
  const TabLabel({ this.text, this.icon });

  final String text;
  final String icon;
}

class Tab extends Component {
  Tab({
314
    Key key,
315
    this.label,
Hans Muller's avatar
Hans Muller committed
316 317 318
    this.color,
    this.selected: false,
    this.selectedColor
319 320 321 322 323
  }) : super(key: key) {
    assert(label.text != null || label.icon != null);
  }

  final TabLabel label;
Hans Muller's avatar
Hans Muller committed
324
  final Color color;
325
  final bool selected;
Hans Muller's avatar
Hans Muller committed
326
  final Color selectedColor;
327 328 329

  Widget _buildLabelText() {
    assert(label.text != null);
Hans Muller's avatar
Hans Muller committed
330 331
    TextStyle style = new TextStyle(color: selected ? selectedColor : color);
    return new Text(label.text, style: style);
332 333 334 335
  }

  Widget _buildLabelIcon() {
    assert(label.icon != null);
Hans Muller's avatar
Hans Muller committed
336 337 338
    Color iconColor = selected ? selectedColor : color;
    sky.ColorFilter filter = new sky.ColorFilter.mode(iconColor, sky.TransferMode.srcATop);
    return new Icon(type: label.icon, size: _kTabIconSize, colorFilter: filter);
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362
  }

  Widget build() {
    Widget labelContents;
    if (label.icon == null) {
      labelContents = _buildLabelText();
    } else if (label.text == null) {
      labelContents = _buildLabelIcon();
    } else {
      labelContents = new Flex(
        <Widget>[
          new Container(
            child: _buildLabelIcon(),
            margin: const EdgeDims.only(bottom: 10.0)
          ),
          _buildLabelText()
        ],
        justifyContent: FlexJustifyContent.center,
        alignItems: FlexAlignItems.center,
        direction: FlexDirection.vertical
      );
    }

    Container centeredLabel = new Container(
Hans Muller's avatar
Hans Muller committed
363
      child: new Center(child: labelContents),
364 365 366 367 368 369 370 371
      constraints: new BoxConstraints(minWidth: _kMinTabWidth),
      padding: _kTabLabelPadding
    );

    return new InkWell(child: centeredLabel);
  }
}

372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392
class _TabsScrollBehavior extends BoundedBehavior {
  _TabsScrollBehavior({ double contentsSize: 0.0, double containerSize: 0.0 })
    : super(contentsSize: contentsSize, containerSize: containerSize);

  bool isScrollable = true;

  Simulation release(double position, double velocity) {
    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;
  }
}

393 394
class TabBar extends Scrollable {
  TabBar({
395
    Key key,
396 397 398
    this.labels,
    this.selectedIndex: 0,
    this.onChanged,
399
    this.isScrollable: false
400
  }) : super(key: key, scrollDirection: ScrollDirection.horizontal);
401 402 403 404

  Iterable<TabLabel> labels;
  int selectedIndex;
  SelectedIndexChanged onChanged;
405
  bool isScrollable;
406

407 408
  Size _tabBarSize;
  List<double> _tabWidths;
409
  AnimationPerformance _indicatorAnimation;
410
  AnimationPerformance _scrollAnimation;
411 412 413 414 415 416

  void initState() {
    super.initState();
    _indicatorAnimation = new AnimationPerformance()
      ..duration = _kTabBarScroll
      ..variable = new AnimatedRect(null, curve: ease);
417 418 419
    _scrollAnimation = new AnimationPerformance()
      ..duration = _kTabBarScroll
      ..variable = new AnimatedValue<double>(0.0, curve: ease);
420
  }
421

422 423 424 425 426
  void syncFields(TabBar source) {
    super.syncFields(source);
    labels = source.labels;
    selectedIndex = source.selectedIndex;
    onChanged = source.onChanged;
427 428
    isScrollable = source.isScrollable;
    if (!isScrollable)
429
      scrollTo(0.0);
430
    scrollBehavior.isScrollable = source.isScrollable;
431 432
  }

433 434 435 436
  AnimatedRect get _indicatorRect => _indicatorAnimation.variable as AnimatedRect;

  void _startIndicatorAnimation(int fromTabIndex, int toTabIndex) {
    _indicatorRect
437
      ..begin = (_indicatorRect.value == null ? _tabIndicatorRect(fromTabIndex) : _indicatorRect.value)
438 439 440 441 442 443
      ..end = _tabIndicatorRect(toTabIndex);
    _indicatorAnimation
      ..progress = 0.0
      ..play();
  }

444 445
  ScrollBehavior createScrollBehavior() => new _TabsScrollBehavior();
  _TabsScrollBehavior get scrollBehavior => super.scrollBehavior;
446

447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470
  Rect _tabRect(int tabIndex) {
    assert(_tabBarSize != null);
    assert(_tabWidths != null);
    assert(tabIndex >= 0 && tabIndex < _tabWidths.length);
    double tabLeft = 0.0;
    if (tabIndex > 0)
      tabLeft = _tabWidths.take(tabIndex).reduce((sum, width) => sum + width);
    double tabTop = 0.0;
    double tabBottom = _tabBarSize.height -_kTabIndicatorHeight;
    double tabRight = tabLeft + _tabWidths[tabIndex];
    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);
  }

  double _centeredTabScrollOffset(int tabIndex) {
    double viewportWidth = scrollBehavior.containerSize;
    return (_tabRect(tabIndex).left + _tabWidths[tabIndex] / 2.0 - viewportWidth / 2.0)
      .clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset);
  }

471
  EventDisposition _handleTap(int tabIndex) {
472 473
    if (tabIndex != selectedIndex) {
      if (_tabWidths != null) {
474
        if (isScrollable)
475
          scrollTo(_centeredTabScrollOffset(tabIndex), animation: _scrollAnimation);
476
        _startIndicatorAnimation(selectedIndex, tabIndex);
477 478 479
      }
      if (onChanged != null)
        onChanged(tabIndex);
480
      return EventDisposition.processed;
481
    }
482
    return EventDisposition.ignored;
483 484
  }

Hans Muller's avatar
Hans Muller committed
485
  Widget _toTab(TabLabel label, int tabIndex, Color color, Color selectedColor) {
486
    return new Listener(
487 488
      child: new Tab(
        label: label,
Hans Muller's avatar
Hans Muller committed
489 490 491
        color: color,
        selected: tabIndex == selectedIndex,
        selectedColor: selectedColor
492
      ),
493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528
      onGestureTap: (_) => _handleTap(tabIndex)
    );
  }

  void _layoutChanged(Size tabBarSize, List<double> tabWidths) {
    setState(() {
      _tabBarSize = tabBarSize;
      _tabWidths = tabWidths;
      scrollBehavior.containerSize = _tabBarSize.width;
      scrollBehavior.contentsSize = _tabWidths.reduce((sum, width) => sum + width);
    });
  }

  Widget buildContent() {
    assert(labels != null && labels.isNotEmpty);

    ThemeData themeData = Theme.of(this);
    Color backgroundColor = themeData.primaryColor;
    Color indicatorColor = themeData.accentColor;
    if (indicatorColor == backgroundColor) {
      indicatorColor = colors.white;
    }

    TextStyle textStyle;
    IconThemeColor iconThemeColor;
    switch (themeData.primaryColorBrightness) {
      case ThemeBrightness.light:
        textStyle = typography.black.body1;
        iconThemeColor = IconThemeColor.black;
        break;
      case ThemeBrightness.dark:
        textStyle = typography.white.body1;
        iconThemeColor = IconThemeColor.white;
        break;
    }

Hans Muller's avatar
Hans Muller committed
529 530 531 532 533 534 535 536 537
    List<Widget> tabs = <Widget>[];
    bool textAndIcons = false;
    int tabIndex = 0;
    for (TabLabel label in labels) {
      tabs.add(_toTab(label, tabIndex++, textStyle.color, indicatorColor));
      if (label.text != null && label.icon != null)
        textAndIcons = true;
    }

538 539 540 541 542 543 544 545 546
    Matrix4 transform = new Matrix4.identity();
    transform.translate(-scrollOffset, 0.0);

    return new Transform(
      transform: transform,
      child: new IconTheme(
        data: new IconThemeData(color: iconThemeColor),
        child: new DefaultTextStyle(
          style: textStyle,
547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562
          child: new BuilderTransition(
            variables: [_indicatorRect],
            direction: Direction.forward,
            performance: _indicatorAnimation,
            builder: () {
              return new TabBarWrapper(
                children: tabs,
                selectedIndex: selectedIndex,
                backgroundColor: backgroundColor,
                indicatorColor: indicatorColor,
                indicatorRect: _indicatorRect.value,
                textAndIcons: textAndIcons,
                isScrollable: isScrollable,
                onLayoutChanged: _layoutChanged
              );
            }
563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585
          )
        )
      )
    );
  }
}

class TabNavigatorView {
  TabNavigatorView({ this.label, this.builder });

  final TabLabel label;
  final Builder builder;

  Widget buildContent() {
    assert(builder != null);
    Widget content = builder();
    assert(content != null);
    return content;
  }
}

class TabNavigator extends Component {
  TabNavigator({
586
    Key key,
587 588 589
    this.views,
    this.selectedIndex: 0,
    this.onChanged,
590
    this.isScrollable: false
591 592 593 594 595
  }) : super(key: key);

  final List<TabNavigatorView> views;
  final int selectedIndex;
  final SelectedIndexChanged onChanged;
596
  final bool isScrollable;
597 598 599 600 601 602 603 604 605 606 607 608 609 610

  void _handleSelectedIndexChanged(int tabIndex) {
    if (onChanged != null)
      onChanged(tabIndex);
  }

  Widget build() {
    assert(views != null && views.isNotEmpty);
    assert(selectedIndex >= 0 && selectedIndex < views.length);

    TabBar tabBar = new TabBar(
      labels: views.map((view) => view.label),
      onChanged: _handleSelectedIndexChanged,
      selectedIndex: selectedIndex,
611
      isScrollable: isScrollable
612 613 614 615 616 617 618 619
    );

    Widget content = views[selectedIndex].buildContent();
    return new Flex([tabBar, new Flexible(child: content)],
      direction: FlexDirection.vertical
    );
  }
}