blob: 855aea57734ad11082ffec31589df2f3c116af74 [file]
// 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:async';
import 'dart:math' as math;
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
import 'app_bar.dart';
import 'colors.dart';
import 'debug.dart';
import 'icon.dart';
import 'icon_theme.dart';
import 'icon_theme_data.dart';
import 'ink_well.dart';
import 'material.dart';
import 'theme.dart';
typedef void _TabLayoutChanged(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 EdgeInsets _kTabLabelPadding = const EdgeInsets.symmetric(horizontal: 12.0);
const double _kTabBarScrollDrag = 0.025;
const Duration _kTabBarScroll = const Duration(milliseconds: 200);
// Curves for the leading and trailing edge of the selected tab indicator.
const Curve _kTabIndicatorLeadingCurve = Curves.easeOut;
const Curve _kTabIndicatorTrailingCurve = Curves.easeIn;
// The additional factor of 5 is to further increase sensitivity to swipe
// gestures and was determined "experimentally".
final double _kMinFlingVelocity = kPixelScrollTolerance.velocity / 5.0;
class _TabBarParentData extends ContainerBoxParentDataMixin<RenderBox> { }
class _RenderTabBar extends RenderBox with
ContainerRenderObjectMixin<RenderBox, _TabBarParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _TabBarParentData> {
_RenderTabBar(this.onLayoutChanged);
int _selectedIndex;
int get selectedIndex => _selectedIndex;
set selectedIndex(int value) {
if (_selectedIndex != value) {
_selectedIndex = value;
markNeedsPaint();
}
}
Color _indicatorColor;
Color get indicatorColor => _indicatorColor;
set indicatorColor(Color value) {
if (_indicatorColor != value) {
_indicatorColor = value;
markNeedsPaint();
}
}
Rect _indicatorRect;
Rect get indicatorRect => _indicatorRect;
set indicatorRect(Rect value) {
if (_indicatorRect != value) {
_indicatorRect = value;
markNeedsPaint();
}
}
bool _textAndIcons;
bool get textAndIcons => _textAndIcons;
set textAndIcons(bool value) {
if (_textAndIcons != value) {
_textAndIcons = value;
markNeedsLayout();
}
}
bool _isScrollable;
bool get isScrollable => _isScrollable;
set isScrollable(bool value) {
if (_isScrollable != value) {
_isScrollable = value;
markNeedsLayout();
}
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _TabBarParentData)
child.parentData = new _TabBarParentData();
}
@override
double computeMinIntrinsicWidth(double height) {
double maxWidth = 0.0;
RenderBox child = firstChild;
while (child != null) {
maxWidth = math.max(maxWidth, child.getMinIntrinsicWidth(height));
final _TabBarParentData childParentData = child.parentData;
child = childParentData.nextSibling;
}
return isScrollable ? maxWidth : maxWidth * childCount;
}
@override
double computeMaxIntrinsicWidth(double height) {
double maxWidth = 0.0;
double totalWidth = 0.0;
RenderBox child = firstChild;
while (child != null) {
double childWidth = child.getMaxIntrinsicWidth(height);
maxWidth = math.max(maxWidth, childWidth);
totalWidth += childWidth;
final _TabBarParentData childParentData = child.parentData;
child = childParentData.nextSibling;
}
return isScrollable ? totalWidth : maxWidth * childCount;
}
double get _tabHeight => textAndIcons ? _kTextAndIconTabHeight : _kTabHeight;
double get _tabBarHeight => _tabHeight + _kTabIndicatorHeight;
@override
double computeMinIntrinsicHeight(double width) => _tabBarHeight;
@override
double computeMaxIntrinsicHeight(double width) => _tabBarHeight;
void layoutFixedWidthTabs() {
double tabWidth = size.width / childCount;
BoxConstraints tabConstraints =
new BoxConstraints.tightFor(width: tabWidth, height: _tabHeight);
double x = 0.0;
RenderBox child = firstChild;
while (child != null) {
child.layout(tabConstraints);
final _TabBarParentData childParentData = child.parentData;
childParentData.offset = new Offset(x, 0.0);
x += tabWidth;
child = childParentData.nextSibling;
}
}
double layoutScrollableTabs() {
BoxConstraints tabConstraints = new BoxConstraints(
minWidth: _kMinTabWidth,
maxWidth: _kMaxTabWidth,
minHeight: _tabHeight,
maxHeight: _tabHeight
);
double x = 0.0;
RenderBox child = firstChild;
while (child != null) {
child.layout(tabConstraints, parentUsesSize: true);
final _TabBarParentData childParentData = child.parentData;
childParentData.offset = new Offset(x, 0.0);
x += child.size.width;
child = childParentData.nextSibling;
}
return x;
}
Size layoutSize;
List<double> layoutWidths;
_TabLayoutChanged onLayoutChanged;
void reportLayoutChangedIfNeeded() {
assert(onLayoutChanged != null);
List<double> widths = new List<double>(childCount);
if (!isScrollable && childCount > 0) {
double tabWidth = size.width / childCount;
widths.fillRange(0, widths.length, tabWidth);
} else if (isScrollable) {
RenderBox child = firstChild;
int childIndex = 0;
while (child != null) {
widths[childIndex++] = child.size.width;
final _TabBarParentData childParentData = child.parentData;
child = childParentData.nextSibling;
}
assert(childIndex == widths.length);
}
if (size != layoutSize || widths != layoutWidths) {
layoutSize = size;
layoutWidths = widths;
onLayoutChanged(layoutSize, layoutWidths);
}
}
@override
void performLayout() {
assert(constraints is BoxConstraints);
if (childCount == 0)
return;
if (isScrollable) {
double tabBarWidth = layoutScrollableTabs();
size = constraints.constrain(new Size(tabBarWidth, _tabBarHeight));
} else {
size = constraints.constrain(new Size(constraints.maxWidth, _tabBarHeight));
layoutFixedWidthTabs();
}
if (onLayoutChanged != null)
reportLayoutChangedIfNeeded();
}
@override
bool hitTestChildren(HitTestResult result, { Point position }) {
return defaultHitTestChildren(result, position: position);
}
void _paintIndicator(Canvas canvas, RenderBox selectedTab, Offset offset) {
if (indicatorColor == null)
return;
if (indicatorRect != null) {
canvas.drawRect(indicatorRect.shift(offset), new Paint()..color = indicatorColor);
return;
}
final Size size = new Size(selectedTab.size.width, _kTabIndicatorHeight);
final _TabBarParentData selectedTabParentData = selectedTab.parentData;
final Point point = new Point(
selectedTabParentData.offset.dx,
_tabBarHeight - _kTabIndicatorHeight
);
canvas.drawRect((point + offset) & size, new Paint()..color = indicatorColor);
}
@override
void paint(PaintingContext context, Offset offset) {
int index = 0;
RenderBox child = firstChild;
while (child != null) {
final _TabBarParentData childParentData = child.parentData;
context.paintChild(child, childParentData.offset + offset);
if (index++ == selectedIndex)
_paintIndicator(context.canvas, child, offset);
child = childParentData.nextSibling;
}
}
}
class _TabBarWrapper extends MultiChildRenderObjectWidget {
_TabBarWrapper({
Key key,
List<Widget> children,
this.selectedIndex,
this.indicatorColor,
this.indicatorRect,
this.textAndIcons,
this.isScrollable: false,
this.onLayoutChanged
}) : super(key: key, children: children);
final int selectedIndex;
final Color indicatorColor;
final Rect indicatorRect;
final bool textAndIcons;
final bool isScrollable;
final _TabLayoutChanged onLayoutChanged;
@override
_RenderTabBar createRenderObject(BuildContext context) {
_RenderTabBar result = new _RenderTabBar(onLayoutChanged);
updateRenderObject(context, result);
return result;
}
@override
void updateRenderObject(BuildContext context, _RenderTabBar renderObject) {
renderObject
..selectedIndex = selectedIndex
..indicatorColor = indicatorColor
..indicatorRect = indicatorRect
..textAndIcons = textAndIcons
..isScrollable = isScrollable
..onLayoutChanged = onLayoutChanged;
}
}
/// Signature for building icons for tabs.
///
/// See also:
///
/// * [TabLabel]
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.
class TabLabel {
/// Creates a tab label description.
///
/// At least one of [text], [icon], or [iconBuilder] must be non-null.
const TabLabel({ this.text, this.icon, this.iconBuilder });
/// The text to display as the label of the tab.
final String text;
/// The icon to display as the label of the tab.
///
/// The size and color of the icon is configured automatically using an
/// [IconTheme] and therefore does not need to be explicitly given in the
/// icon widget.
///
/// See [Icon], [ImageIcon].
final Widget icon;
/// Called if [icon] is null to build an icon as a label for this tab.
///
/// The color argument to this builder is the color that an ordinary icon
/// would have been drawn with. The color reflects that tab's selection state.
///
/// Return value must be non-null.
final TabLabelIconBuilder iconBuilder;
/// Whether this label has any text (specified using [text]).
bool get hasText => text != null;
/// Whether this label has an icon (specified either using [icon] or [iconBuilder]).
bool get hasIcon => icon != null || iconBuilder != null;
}
class _Tab extends StatelessWidget {
_Tab({
Key key,
this.onSelected,
this.label,
this.color
}) : super(key: key) {
assert(label.hasText || label.hasIcon);
}
final VoidCallback onSelected;
final TabLabel label;
final Color color;
Widget _buildLabelText() {
assert(label.text != null);
TextStyle style = new TextStyle(color: color);
return new Text(
label.text,
style: style,
softWrap: false,
overflow: TextOverflow.fade
);
}
Widget _buildLabelIcon(BuildContext context) {
assert(label.hasIcon);
if (label.icon != null) {
return new IconTheme.merge(
context: context,
data: new IconThemeData(
color: color,
size: 24.0
),
child: label.icon
);
} else {
return new SizedBox(
width: 24.0,
height: 24.0,
child: label.iconBuilder(context, color)
);
}
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context));
Widget labelContent;
if (!label.hasIcon) {
labelContent = _buildLabelText();
} else if (!label.hasText) {
labelContent = _buildLabelIcon(context);
} else {
labelContent = new Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
new Container(
child: _buildLabelIcon(context),
margin: const EdgeInsets.only(bottom: 10.0)
),
_buildLabelText()
]
);
}
Container centeredLabel = new Container(
child: new Center(child: labelContent, widthFactor: 1.0, heightFactor: 1.0),
constraints: new BoxConstraints(minWidth: _kMinTabWidth),
padding: _kTabLabelPadding
);
return new InkWell(
onTap: onSelected,
child: centeredLabel
);
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$label');
}
}
class _TabsScrollBehavior extends BoundedBehavior {
_TabsScrollBehavior();
@override
bool isScrollable = true;
@override
Simulation createScrollSimulation(double position, double velocity) {
if (!isScrollable)
return null;
double velocityPerSecond = velocity * 1000.0;
return new BoundedFrictionSimulation(
_kTabBarScrollDrag, position, velocityPerSecond, minScrollOffset, maxScrollOffset
);
}
@override
double applyCurve(double scrollOffset, double scrollDelta) {
return (isScrollable) ? super.applyCurve(scrollOffset, scrollDelta) : 0.0;
}
}
/// An abstract interface through which [TabBarSelection] reports changes.
abstract class TabBarSelectionAnimationListener {
/// Called when the status of the [TabBarSelection] animation changes.
void handleStatusChange(AnimationStatus status);
/// Called on each animation frame when the [TabBarSelection] animation ticks.
void handleProgressChange();
/// Called when the [TabBarSelection] is deactivated.
///
/// Implementations typically drop their reference to the [TabBarSelection]
/// during this callback.
void handleSelectionDeactivate();
}
/// Coordinates the tab selection between a [TabBar] and a [TabBarView].
///
/// Place a [TabBarSelection] widget in the tree such that it is a common
/// ancestor of both the [TabBar] and the [TabBarView]. Both the [TabBar] and
/// the [TabBarView] can alter which tab is selected. They coodinate by
/// listening to the selection value stored in a common ancestor
/// [TabBarSelection] selection widget.
class TabBarSelection<T> extends StatefulWidget {
/// Creates a tab bar selection.
///
/// The values argument must be non-null, non-empty, and each value must be
/// unique. The value argument must either be null or contained in the values
/// argument. The child argument must be non-null.
TabBarSelection({
Key key,
this.value,
@required this.values,
this.onChanged,
@required this.child
}) : super(key: key) {
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);
assert(child != null);
}
/// The current value of the selection.
final T value;
/// The list of possible values that the selection can obtain.
List<T> values;
/// Called when the value of the selection should change.
///
/// The tab bar selection passes the new value to the callback but does not
/// actually change state until the parent widget rebuilds the tab bar
/// selection with the new value.
///
/// If null, the tab bar selection cannot change value.
final ValueChanged<T> onChanged;
/// The widget below this widget in the tree.
final Widget child;
@override
TabBarSelectionState<T> createState() => new TabBarSelectionState<T>();
/// The state from the closest instance of this class that encloses the given context.
static TabBarSelectionState<dynamic/*=T*/> of/*<T>*/(BuildContext context) {
return context.ancestorStateOfType(new TypeMatcher<TabBarSelectionState<dynamic/*=T*/>>());
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('current tab: $value');
description.add('available tabs: $values');
}
}
/// State for a [TabBarSelection] widget.
///
/// Subclasses of [TabBarSelection] typically use [State] objects that extend
/// this class.
class TabBarSelectionState<T> extends State<TabBarSelection<T>> with SingleTickerProviderStateMixin {
// Both the TabBar and TabBarView classes access _controller because they
// alternately drive selection progress between tabs.
AnimationController _controller;
/// An animation that updates as the selected tab changes.
Animation<double> get animation => _controller.view;
final Map<T, int> _valueToIndex = new Map<T, int>();
@override
void initState() {
super.initState();
_controller = new AnimationController(
duration: _kTabBarScroll,
value: 1.0,
vsync: this,
);
_value = config.value ?? PageStorage.of(context)?.readState(context) ?? values.first;
// 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;
_previousValue = _value;
_initValueToIndex();
}
@override
void didUpdateConfig(TabBarSelection<T> oldConfig) {
super.didUpdateConfig(oldConfig);
if (values != oldConfig.values)
_initValueToIndex();
}
void _initValueToIndex() {
_valueToIndex.clear();
int index = 0;
for (T value in values)
_valueToIndex[value] = index++;
}
void _writeValue() {
PageStorage.of(context)?.writeState(context, _value);
}
/// The list of possible values that the selection can obtain.
List<T> get values => config.values;
/// The previously selected value.
///
/// When the tab selection changes, the tab selection animates from the
/// previously selected value to the new value.
T get previousValue => _previousValue;
T _previousValue;
/// Whether the tab selection is in the process of animating from one value to
/// another.
// TODO(abarth): Try computing this value from _controller.state so we don't
// need to keep a separate bool in sync.
bool get valueIsChanging => _valueIsChanging;
bool _valueIsChanging = false;
/// The index of a given value in [values].
///
/// Runs in constant time.
int indexOf(T tabValue) => _valueToIndex[tabValue];
/// The index of the currently selected value.
int get index => _valueToIndex[value];
/// The index of the previoulsy selected value.
int get previousIndex => indexOf(_previousValue);
/// The currently selected value.
///
/// Writing to this field will cause the tab selection to animate from the
/// previous value to the new value.
T get value => _value;
T _value;
set value(T newValue) {
if (newValue == _value)
return;
_previousValue = _value;
_value = newValue;
_writeValue();
_valueIsChanging = true;
// If the selected value change was triggered by a drag gesture, the current
// value of _controller.value will reflect where the gesture ended. While
// the drag was underway the controller's value indicates where the indicator
// and TabBarView scrollPositions 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 right. That is unless the index of the selected value was 0
// or values.length - 1. In those cases the controller's value just moves between
// the selected tab and the adjacent one. So: convert the controller's value
// here to reflect the fact that we're now moving between (just) the previous
// and current selection index.
double value;
if (_controller.status == AnimationStatus.completed)
value = 0.0;
else if (_previousValue == values.first)
value = _controller.value;
else if (_previousValue == values.last)
value = 1.0 - _controller.value;
else if (previousIndex < index)
value = (_controller.value - 0.5) * 2.0;
else
value = 1.0 - _controller.value * 2.0;
_controller
..value = value
..forward().then((_) {
// TODO(abarth): Consider using a status listener and checking for
// AnimationStatus.completed.
if (_controller.value == 1.0) {
if (config.onChanged != null)
config.onChanged(_value);
_valueIsChanging = false;
}
});
}
final List<TabBarSelectionAnimationListener> _animationListeners = <TabBarSelectionAnimationListener>[];
/// Calls listener methods every time the value or status of the selection animation changes.
///
/// Listeners can be removed with [removeAnimationListener].
void addAnimationListener(TabBarSelectionAnimationListener listener) {
_animationListeners.add(listener);
_controller
..addStatusListener(listener.handleStatusChange)
..addListener(listener.handleProgressChange);
}
/// Stop calling listener methods every time the value or status of the animation changes.
///
/// Listeners can be added with [addAnimationListener].
void removeAnimationListener(TabBarSelectionAnimationListener listener) {
_animationListeners.remove(listener);
_controller
..removeStatusListener(listener.handleStatusChange)
..removeListener(listener.handleProgressChange);
}
@override
void deactivate() {
_controller.stop();
for (TabBarSelectionAnimationListener listener in _animationListeners.toList()) {
listener.handleSelectionDeactivate();
removeAnimationListener(listener);
}
assert(_animationListeners.isEmpty);
_writeValue();
super.deactivate();
}
@override
Widget build(BuildContext context) {
return config.child;
}
}
// Used when the user is dragging the TabBar or the TabBarView left or right.
// Dragging from the selected tab to the left varies t between 0.5 and 0.0.
// Dragging towards the tab on the right varies t between 0.5 and 1.0.
class _TabIndicatorTween extends Tween<Rect> {
_TabIndicatorTween({ Rect begin, this.middle, Rect end }) : super(begin: begin, end: end);
final Rect middle;
@override
Rect lerp(double t) {
return t <= 0.5
? Rect.lerp(begin, middle, t * 2.0)
: Rect.lerp(middle, end, (t - 0.5) * 2.0);
}
}
/// A widget that displays a horizontal row of tabs, one per label.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
///
/// * [TabBarSelection]
/// * [TabBarView]
/// * [AppBar.tabBar]
/// * <https://www.google.com/design/spec/components/tabs.html>
class TabBar<T> extends Scrollable implements AppBarBottomWidget {
/// Creates a widget that displays a horizontal row of tabs, one per label.
///
/// The [labels] argument must not be null.
TabBar({
Key key,
@required this.labels,
this.isScrollable: false,
this.indicatorColor,
this.labelColor
}) : super(key: key, scrollDirection: Axis.horizontal) {
assert(labels != null);
}
/// The labels to display in the tabs.
///
/// The [TabBarSelection.values] are used as keys for this map to determine
/// which tab label is selected.
final Map<T, TabLabel> labels;
/// 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.
final bool isScrollable;
/// 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;
/// The color of selected tab labels. Unselected tab labels are rendered
/// with the same color rendered at 70% opacity. If this parameter is null then
/// the color of the theme's body2 text color is used.
final Color labelColor;
/// The height of the tab labels and indicator.
@override
double get bottomHeight {
for (TabLabel label in labels.values) {
if (label.hasText && label.hasIcon)
return _kTextAndIconTabHeight + _kTabIndicatorHeight;
}
return _kTabHeight + _kTabIndicatorHeight;
}
@override
_TabBarState<T> createState() => new _TabBarState<T>();
}
class _TabBarState<T> extends ScrollableState<TabBar<T>> implements TabBarSelectionAnimationListener {
TabBarSelectionState<T> _selection;
bool _valueIsChanging = false;
int _lastSelectedIndex = -1;
void _initSelection(TabBarSelectionState<T> newSelection) {
if (_selection == newSelection)
return;
_selection?.removeAnimationListener(this);
_selection = newSelection;
_selection?.addAnimationListener(this);
if (_selection != null)
_lastSelectedIndex = _selection.index;
}
@override
void didUpdateConfig(TabBar<T> oldConfig) {
super.didUpdateConfig(oldConfig);
if (config.isScrollable != oldConfig.isScrollable) {
scrollBehavior.isScrollable = config.isScrollable;
if (!config.isScrollable)
scrollTo(0.0);
}
}
@override
void dispose() {
_selection?.removeAnimationListener(this);
super.dispose();
}
@override
void handleSelectionDeactivate() {
_selection = null;
}
// Initialize _indicatorTween for interactive dragging between the tab on the left
// and the tab on the right. In this case _selection.animation.value is 0.5 when
// the indicator is below the selected tab, 0.0 when it's under the left tab, and 1.0
// when it's under the tab on the right.
void _initIndicatorTweenForDrag() {
assert(!_valueIsChanging);
int index = _selection.index;
int beginIndex = math.max(0, index - 1);
int endIndex = math.min(config.labels.length - 1, index + 1);
if (beginIndex == index || endIndex == index) {
_indicatorTween = new RectTween(
begin: _tabIndicatorRect(beginIndex),
end: _tabIndicatorRect(endIndex)
);
} else {
_indicatorTween = new _TabIndicatorTween(
begin: _tabIndicatorRect(beginIndex),
middle: _tabIndicatorRect(index),
end: _tabIndicatorRect(endIndex)
);
}
}
// Initialize _indicatorTween for animating the selected tab indicator from the
// previously selected tab to the newly selected one. In this case
// _selection.animation.value is 0.0 when the indicator is below the previously
// selected tab, and 1.0 when it's under the newly selected one.
void _initIndicatorTweenForAnimation() {
assert(_valueIsChanging);
_indicatorTween = new RectTween(
begin: _indicatorRect ?? _tabIndicatorRect(_selection.previousIndex),
end: _tabIndicatorRect(_selection.index)
);
}
@override
void handleStatusChange(AnimationStatus status) {
if (config.labels.length == 0)
return;
if (_valueIsChanging && status == AnimationStatus.completed) {
_valueIsChanging = false;
setState(() {
_initIndicatorTweenForDrag();
_indicatorRect = _tabIndicatorRect(_selection.index);
});
}
}
@override
void handleProgressChange() {
if (config.labels.length == 0 || _selection == null)
return;
if (_lastSelectedIndex != _selection.index) {
_valueIsChanging = true;
if (config.isScrollable)
scrollTo(_centeredTabScrollOffset(_selection.index), duration: _kTabBarScroll);
_initIndicatorTweenForAnimation();
_lastSelectedIndex = _selection.index;
} else if (_indicatorTween == null) {
_initIndicatorTweenForDrag();
}
Rect oldRect = _indicatorRect;
double t = _selection.animation.value;
// When _valueIsChanging is false, we're animating based on drag gesture and
// want linear selected tab indicator motion. When _valueIsChanging is true,
// a ticker is driving the selection change and we want to curve the animation.
// In this case the leading and trailing edges of the move at different rates.
// The easiest way to do this is to lerp 2 rects, and piece them together into 1.
if (!_valueIsChanging) {
_indicatorRect = _indicatorTween.lerp(t);
} else {
Rect leftRect, rightRect;
if (_selection.index > _selection.previousIndex) {
// Moving to the right - right edge is leading.
rightRect = _indicatorTween.lerp(_kTabIndicatorLeadingCurve.transform(t));
leftRect = _indicatorTween.lerp(_kTabIndicatorTrailingCurve.transform(t));
} else {
// Moving to the left - left edge is leading.
leftRect = _indicatorTween.lerp(_kTabIndicatorLeadingCurve.transform(t));
rightRect = _indicatorTween.lerp(_kTabIndicatorTrailingCurve.transform(t));
}
_indicatorRect = new Rect.fromLTRB(
leftRect.left, leftRect.top, rightRect.right, rightRect.bottom
);
}
if (oldRect != _indicatorRect)
setState(() { /* The indicator rect has changed. */ });
}
Size _viewportSize = Size.zero;
Size _tabBarSize;
List<double> _tabWidths;
Rect _indicatorRect;
Tween<Rect> _indicatorTween;
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((double sum, double width) => sum + width);
final double tabTop = 0.0;
final double tabBottom = _tabBarSize.height - _kTabIndicatorHeight;
final 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);
}
@override
ExtentScrollBehavior createScrollBehavior() {
return new _TabsScrollBehavior()
..isScrollable = config.isScrollable;
}
@override
_TabsScrollBehavior get scrollBehavior => super.scrollBehavior;
double _centeredTabScrollOffset(int tabIndex) {
double viewportWidth = scrollBehavior.containerExtent;
Rect tabRect = _tabRect(tabIndex);
return (tabRect.left + tabRect.width / 2.0 - viewportWidth / 2.0)
.clamp(scrollBehavior.minScrollOffset, scrollBehavior.maxScrollOffset);
}
void _handleTabSelected(int tabIndex) {
if (_selection != null && tabIndex != _selection.index)
setState(() {
_selection.value = _selection.values[tabIndex];
});
}
Widget _toTab(TabLabel label, int tabIndex, Color color, Color selectedColor) {
Color labelColor = color;
if (_selection != null) {
final bool isSelectedTab = tabIndex == _selection.index;
final bool isPreviouslySelectedTab = tabIndex == _selection.previousIndex;
labelColor = isSelectedTab ? selectedColor : color;
if (_selection.valueIsChanging) {
if (isSelectedTab)
labelColor = Color.lerp(color, selectedColor, _selection.animation.value);
else if (isPreviouslySelectedTab)
labelColor = Color.lerp(selectedColor, color, _selection.animation.value);
}
}
return new _Tab(
onSelected: () { _handleTabSelected(tabIndex); },
label: label,
color: labelColor
);
}
void _updateScrollBehavior() {
didUpdateScrollBehavior(scrollBehavior.updateExtents(
containerExtent: config.scrollDirection == Axis.vertical ? _viewportSize.height : _viewportSize.width,
contentExtent: _tabWidths.reduce((double sum, double width) => sum + width),
scrollOffset: scrollOffset
));
}
void _layoutChanged(Size tabBarSize, List<double> tabWidths) {
// This is bad. We should use a LayoutBuilder or CustomMultiChildLayout or some such.
// As designed today, tabs are always lagging one frame behind, taking two frames
// to handle a layout change.
_tabBarSize = tabBarSize;
_tabWidths = tabWidths;
_indicatorRect = _selection != null ? _tabIndicatorRect(_selection.index) : Rect.zero;
_updateScrollBehavior();
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (mounted) {
setState(() {
// the changes were made at layout time
// TODO(ianh): remove this setState: https://github.com/flutter/flutter/issues/5749
});
}
});
}
Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions) {
// We make various state changes here but don't have to do so in a
// setState() callback because we are called during layout and all
// we're updating is the new offset, which we are providing to the
// render object via our return value.
_viewportSize = dimensions.containerSize;
_updateScrollBehavior();
if (config.isScrollable && _selection != null)
scrollTo(_centeredTabScrollOffset(_selection.index), duration: _kTabBarScroll);
return scrollOffsetToPixelDelta(scrollOffset);
}
@override
Widget buildContent(BuildContext context) {
TabBarSelectionState<T> newSelection = TabBarSelection.of(context);
_initSelection(newSelection);
assert(config.labels.isNotEmpty);
assert(Material.of(context) != null);
ThemeData themeData = Theme.of(context);
Color backgroundColor = Material.of(context).color;
Color indicatorColor = config.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 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.
indicatorColor = Colors.white;
}
final TextStyle textStyle = themeData.primaryTextTheme.body2;
final Color selectedLabelColor = config.labelColor ?? themeData.primaryTextTheme.body2.color;
final Color labelColor = selectedLabelColor.withAlpha(0xB2); // 70% alpha
List<Widget> tabs = <Widget>[];
bool textAndIcons = false;
int tabIndex = 0;
for (TabLabel label in config.labels.values) {
tabs.add(_toTab(label, tabIndex++, labelColor, selectedLabelColor));
if (label.hasText && label.hasIcon)
textAndIcons = true;
}
Widget contents = new DefaultTextStyle(
style: textStyle,
child: new _TabBarWrapper(
children: tabs,
selectedIndex: _selection?.index,
indicatorColor: indicatorColor,
indicatorRect: _indicatorRect,
textAndIcons: textAndIcons,
isScrollable: config.isScrollable,
onLayoutChanged: _layoutChanged
)
);
if (config.isScrollable) {
return new Viewport(
mainAxis: Axis.horizontal,
paintOffset: scrollOffsetToPixelDelta(scrollOffset),
onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded,
child: contents
);
}
return contents;
}
}
/// A widget that displays the contents of a tab.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
///
/// See also:
///
/// * [TabBarSelection]
/// * [TabBar]
/// * <https://www.google.com/design/spec/components/tabs.html>
class TabBarView<T> extends PageableList {
/// Creates a widget that displays the contents of a tab.
///
/// The [children] argument must not be null and must not be empty.
TabBarView({
Key key,
@required List<Widget> children
}) : super(
key: key,
scrollDirection: Axis.horizontal,
children: children
) {
assert(children != null);
assert(children.length > 1);
}
@override
_TabBarViewState<T> createState() => new _TabBarViewState<T>();
}
class _TabBarViewState<T> extends PageableListState<TabBarView<T>> implements TabBarSelectionAnimationListener {
TabBarSelectionState<T> _selection;
List<Widget> _items;
int get _tabCount => config.children.length;
BoundedBehavior _boundedBehavior;
@override
ExtentScrollBehavior get scrollBehavior {
_boundedBehavior ??= new BoundedBehavior(platform: platform);
return _boundedBehavior;
}
@override
TargetPlatform get platform => Theme.of(context).platform;
void _initSelection(TabBarSelectionState<T> newSelection) {
if (_selection == newSelection)
return;
_selection?.removeAnimationListener(this);
_selection = newSelection;
_selection?.addAnimationListener(this);
if (_selection != null)
_updateItemsAndScrollBehavior();
}
@override
void didUpdateConfig(TabBarView<T> oldConfig) {
super.didUpdateConfig(oldConfig);
if (_selection != null && config.children != oldConfig.children)
_updateItemsForSelectedIndex(_selection.index);
}
@override
void dispose() {
_selection?.removeAnimationListener(this);
super.dispose();
}
@override
void handleSelectionDeactivate() {
_selection = null;
}
void _updateItemsFromChildren(int first, int second, [int third]) {
List<Widget> widgets = config.children;
_items = <Widget>[
new KeyedSubtree.wrap(widgets[first], first),
new KeyedSubtree.wrap(widgets[second], second),
];
if (third != null)
_items.add(new KeyedSubtree.wrap(widgets[third], third));
}
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) {
if (selectedIndex == 0) {
didUpdateScrollBehavior(scrollBehavior.updateExtents(contentExtent: 2.0, containerExtent: 1.0, scrollOffset: 0.0));
} else if (selectedIndex == _tabCount - 1) {
didUpdateScrollBehavior(scrollBehavior.updateExtents(contentExtent: 2.0, containerExtent: 1.0, scrollOffset: 1.0));
} else {
didUpdateScrollBehavior(scrollBehavior.updateExtents(contentExtent: 3.0, containerExtent: 1.0, scrollOffset: 1.0));
}
}
void _updateItemsAndScrollBehavior() {
assert(_selection != null);
final int selectedIndex = _selection.index;
assert(selectedIndex != null);
_updateItemsForSelectedIndex(selectedIndex);
_updateScrollBehaviorForSelectedIndex(selectedIndex);
}
@override
void handleStatusChange(AnimationStatus status) {
}
@override
void handleProgressChange() {
if (_selection == null || !_selection.valueIsChanging)
return;
// The TabBar is driving the TabBarSelection animation.
final Animation<double> animation = _selection.animation;
if (animation.status == AnimationStatus.completed) {
_updateItemsAndScrollBehavior();
return;
}
if (animation.status != AnimationStatus.forward)
return;
final int selectedIndex = _selection.index;
final int previousSelectedIndex = _selection.previousIndex;
if (selectedIndex < previousSelectedIndex) {
_updateItemsFromChildren(selectedIndex, previousSelectedIndex);
scrollTo(new CurveTween(curve: Curves.fastOutSlowIn.flipped).evaluate(new ReverseAnimation(animation)));
} else {
_updateItemsFromChildren(previousSelectedIndex, selectedIndex);
scrollTo(new CurveTween(curve: Curves.fastOutSlowIn).evaluate(animation));
}
}
@override
void dispatchOnScroll() {
if (_selection == null || _selection.valueIsChanging)
return;
// This class is driving the TabBarSelection's animation.
final AnimationController controller = _selection._controller;
if (_selection.index == 0 || _selection.index == _tabCount - 1)
controller.value = scrollOffset;
else
controller.value = scrollOffset / 2.0;
}
@override
Future<Null> fling(double scrollVelocity) {
if (_selection == null || _selection.valueIsChanging)
return new Future<Null>.value();
if (scrollVelocity.abs() > _kMinFlingVelocity) {
final int selectionDelta = scrollVelocity.sign.truncate();
final int targetIndex = (_selection.index + selectionDelta).clamp(0, _tabCount - 1);
if (_selection.index != targetIndex) {
_selection.value = _selection.values[targetIndex];
return new Future<Null>.value();
}
}
final int selectionIndex = _selection.index;
final int settleIndex = snapScrollOffset(scrollOffset).toInt();
if (selectionIndex > 0 && settleIndex != 1) {
final int targetIndex = (selectionIndex + (settleIndex == 2 ? 1 : -1)).clamp(0, _tabCount - 1);
_selection.value = _selection.values[targetIndex];
return new Future<Null>.value();
} else if (selectionIndex == 0 && settleIndex == 1) {
_selection.value = _selection.values[1];
return new Future<Null>.value();
}
return settleScrollOffset();
}
@override
Widget buildContent(BuildContext context) {
TabBarSelectionState<T> newSelection = TabBarSelection.of(context);
_initSelection(newSelection);
return new PageViewport(
itemsWrap: config.itemsWrap,
mainAxis: config.scrollDirection,
startOffset: scrollOffset,
children: _items
);
}
}
/// A widget that displays a visual indicator of which tab is selected.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
///
/// See also:
///
/// * [TabBarSelection]
/// * [TabBarView]
class TabPageSelector<T> extends StatelessWidget {
/// Creates a widget that displays a visual indicator of which tab is selected.
///
/// Requires one of its ancestors to be a [TabBarSelection] widget to enable
/// saving and monitoring the selected tab.
const TabPageSelector({ Key key }) : super(key: key);
Widget _buildTabIndicator(TabBarSelectionState<T> selection, T tab, Animation<double> 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 EdgeInsets.all(4.0),
decoration: new BoxDecoration(
backgroundColor: background,
border: new Border.all(color: selectedColor.end),
shape: BoxShape.circle
)
);
}
@override
Widget build(BuildContext context) {
final TabBarSelectionState<T> selection = TabBarSelection.of(context);
final Color color = Theme.of(context).accentColor;
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.fastOutSlowIn);
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(),
mainAxisSize: MainAxisSize.min
)
);
}
);
}
}