Unverified Commit 24efb55b authored by Hans Muller's avatar Hans Muller Committed by GitHub

Generalized TabBar selected tab indicator (#14741)

parent 21c514fc
......@@ -81,6 +81,7 @@ export 'src/material/stepper.dart';
export 'src/material/switch.dart';
export 'src/material/switch_list_tile.dart';
export 'src/material/tab_controller.dart';
export 'src/material/tab_indicator.dart';
export 'src/material/tabs.dart';
export 'src/material/text_field.dart';
export 'src/material/text_form_field.dart';
......
// Copyright 2018 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 'package:flutter/widgets.dart';
import 'colors.dart';
/// Used with [TabBar.indicator] to draw a horizontal line below the
/// selected tab.
///
/// The selected tab underline is inset from the tab's boundary by [insets].
/// The [borderSide] defines the line's color and weight.
///
/// The [TabBar.indicatorSize] property can be used to define the indicator's
/// bounds in terms of its (centered) widget with [TabIndicatorSize.label],
/// or the entire tab with [TabIndicatorSize.tab].
class UnderlineTabIndicator extends Decoration {
/// Create an underline style selected tab indicator.
///
/// The [borderSide] and [insets] arguments must not be null.
const UnderlineTabIndicator({
this.borderSide: const BorderSide(width: 2.0, color: Colors.white),
this.insets: EdgeInsets.zero,
}) : assert(borderSide != null), assert(insets != null);
/// The color and weight of the horizontal line drawn below the selected tab.
final BorderSide borderSide;
/// Locates the selected tab's underline relative to the tab's boundary.
///
/// The [TabBar.indicatorSize] property can be used to define the
/// tab indicator's bounds in terms of its (centered) tab widget with
/// [TabIndicatorSize.label], or the entire tab with [TabIndicatorSize.tab].
final EdgeInsetsGeometry insets;
@override
Decoration lerpFrom(Decoration a, double t) {
if (a is UnderlineTabIndicator) {
return new UnderlineTabIndicator(
borderSide: BorderSide.lerp(a.borderSide, borderSide, t),
insets: EdgeInsetsGeometry.lerp(a.insets, insets, t),
);
}
return super.lerpFrom(a, t);
}
@override
Decoration lerpTo(Decoration b, double t) {
if (b is UnderlineTabIndicator) {
return new UnderlineTabIndicator(
borderSide: BorderSide.lerp(borderSide, b.borderSide, t),
insets: EdgeInsetsGeometry.lerp(insets, b.insets, t),
);
}
return super.lerpTo(b, t);
}
@override
_UnderlinePainter createBoxPainter([VoidCallback onChanged]) {
return new _UnderlinePainter(this, onChanged);
}
}
class _UnderlinePainter extends BoxPainter {
_UnderlinePainter(this.decoration, VoidCallback onChanged)
: assert(decoration != null), super(onChanged);
final UnderlineTabIndicator decoration;
BorderSide get borderSide => decoration.borderSide;
EdgeInsetsGeometry get insets => decoration.insets;
Rect _indicatorRectFor(Rect rect, TextDirection textDirection) {
assert(rect != null);
assert(textDirection != null);
final Rect indicator = insets.resolve(textDirection).deflateRect(rect);
return new Rect.fromLTWH(
indicator.left,
indicator.bottom - borderSide.width,
indicator.width,
borderSide.width,
);
}
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
assert(configuration != null);
assert(configuration.size != null);
final Rect rect = offset & configuration.size;
final TextDirection textDirection = configuration.textDirection;
final Rect indicator = _indicatorRectFor(rect, textDirection).deflate(borderSide.width / 2.0);
canvas.drawLine(indicator.bottomLeft, indicator.bottomRight, borderSide.toPaint());
}
}
......@@ -3,7 +3,6 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' show lerpDouble;
import 'package:flutter/foundation.dart';
......@@ -18,11 +17,31 @@ import 'ink_well.dart';
import 'material.dart';
import 'material_localizations.dart';
import 'tab_controller.dart';
import 'tab_indicator.dart';
import 'theme.dart';
const double _kTabHeight = 46.0;
const double _kTextAndIconTabHeight = 72.0;
const double _kMinTabWidth = 72.0;
/// Defines how the bounds of the selected tab indicator are computed.
///
/// See also:
/// * [TabBar], which displays a row of tabs.
/// * [TabBarView], which displays a widget for the currently selected tab.
/// * [TabBar.indicator], which defines the appearance of the selected tab
/// indicator relative to the tab's bounds.
enum TabBarIndicatorSize {
/// The tab indicator's bounds are as wide as the space occupied by the tab
/// in the tab bar: from the right edge of the previous tab to the left edge
/// of the next tab.
tab,
/// The tab's bounds are only as wide as the (centered) tab widget itself.
///
/// This value is used to align the tab's label, typically a [Tab]
/// widget's text or icon, with the selected tab indicator.
label,
}
/// A material design [TabBar] tab. If both [icon] and [text] are
/// provided, the text is displayed below the icon.
......@@ -85,18 +104,19 @@ class Tab extends StatelessWidget {
children: <Widget>[
new Container(
child: icon,
margin: const EdgeInsets.only(bottom: 10.0)
margin: const EdgeInsets.only(bottom: 10.0),
),
_buildLabelText()
]
);
}
return new Container(
padding: kTabLabelPadding,
return new SizedBox(
height: height,
constraints: const BoxConstraints(minWidth: _kMinTabWidth),
child: new Center(child: label),
child: new Center(
child: label,
widthFactor: 1.0,
),
);
}
......@@ -266,32 +286,38 @@ double _indexChangeProgress(TabController controller) {
class _IndicatorPainter extends CustomPainter {
_IndicatorPainter({
@required this.controller,
@required this.indicatorWeight,
@required this.indicatorPadding,
@required this.indicator,
@required this.indicatorSize,
@required this.tabKeys,
_IndicatorPainter old,
}) : assert(controller != null),
assert(indicatorWeight != null),
assert(indicatorPadding != null),
assert(indicator != null),
super(repaint: controller.animation) {
if (old != null)
saveTabOffsets(old._currentTabOffsets, old._currentTextDirection);
}
final TabController controller;
final double indicatorWeight;
final EdgeInsetsGeometry indicatorPadding;
final Decoration indicator;
final TabBarIndicatorSize indicatorSize;
final List<GlobalKey> tabKeys;
List<double> _currentTabOffsets;
TextDirection _currentTextDirection;
EdgeInsets _resolvedIndicatorPadding;
Color _color;
Rect _currentRect;
BoxPainter _painter;
bool _needsPaint = false;
void markNeedsPaint() {
_needsPaint = true;
}
void dispose() {
_painter?.dispose();
}
void saveTabOffsets(List<double> tabOffsets, TextDirection textDirection) {
_currentTabOffsets = tabOffsets;
_currentTextDirection = textDirection;
_resolvedIndicatorPadding = indicatorPadding.resolve(_currentTextDirection);
}
// _currentTabOffsets[index] is the offset of the start edge of the tab at index, and
......@@ -323,14 +349,22 @@ class _IndicatorPainter extends CustomPainter {
tabRight = _currentTabOffsets[tabIndex + 1];
break;
}
tabLeft = math.min(tabLeft + _resolvedIndicatorPadding.left, tabRight);
tabRight = math.max(tabRight - _resolvedIndicatorPadding.right, tabLeft);
final double tabTop = tabBarSize.height - indicatorWeight;
return new Rect.fromLTWH(tabLeft, tabTop, tabRight - tabLeft, indicatorWeight);
if (indicatorSize == TabBarIndicatorSize.label) {
final double tabWidth = tabKeys[tabIndex].currentContext.size.width;
final double delta = ((tabRight - tabLeft) - tabWidth) / 2.0;
tabLeft += delta;
tabRight -= delta;
}
return new Rect.fromLTWH(tabLeft, 0.0, tabRight - tabLeft, tabBarSize.height);
}
@override
void paint(Canvas canvas, Size size) {
_needsPaint = false;
_painter ??= indicator.createBoxPainter(markNeedsPaint);
if (controller.indexIsChanging) {
// The user tapped on a tab, the tab controller's animation is running.
final Rect targetRect = indicatorRect(size, controller.index);
......@@ -355,7 +389,12 @@ class _IndicatorPainter extends CustomPainter {
_currentRect = next == null ? middle : Rect.lerp(middle, next, value - index);
}
assert(_currentRect != null);
canvas.drawRect(_currentRect, new Paint()..color = _color);
final ImageConfiguration configuration = new ImageConfiguration(
size: _currentRect.size,
textDirection: _currentTextDirection,
);
_painter.paint(canvas, _currentRect.topLeft, configuration);
}
static bool _tabOffsetsEqual(List<double> a, List<double> b) {
......@@ -370,9 +409,10 @@ class _IndicatorPainter extends CustomPainter {
@override
bool shouldRepaint(_IndicatorPainter old) {
return controller != old.controller
|| indicatorWeight != old.indicatorWeight
|| indicatorPadding != old.indicatorPadding
return _needsPaint
|| controller != old.controller
|| indicator != old.indicator
|| tabKeys.length != old.tabKeys.length
|| (!_tabOffsetsEqual(_currentTabOffsets, old._currentTabOffsets))
|| _currentTextDirection != old._currentTextDirection;
}
......@@ -480,6 +520,9 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
/// The [indicatorWeight] parameter defaults to 2, and must not be null.
///
/// The [indicatorPadding] parameter defaults to [EdgeInsets.zero], and must not be null.
///
/// If [indicator] is not null, then [indicatorWeight], [indicatorPadding], and
/// [indicatorColor] are ignored.
const TabBar({
Key key,
@required this.tabs,
......@@ -488,14 +531,16 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
this.indicatorColor,
this.indicatorWeight: 2.0,
this.indicatorPadding: EdgeInsets.zero,
this.indicator,
this.indicatorSize,
this.labelColor,
this.labelStyle,
this.unselectedLabelColor,
this.unselectedLabelStyle,
}) : assert(tabs != null),
assert(isScrollable != null),
assert(indicatorWeight != null && indicatorWeight > 0.0),
assert(indicatorPadding != null),
assert(indicator != null || (indicatorWeight != null && indicatorWeight > 0.0)),
assert(indicator != null || (indicatorPadding != null)),
super(key: key);
/// Typically a list of two or more [Tab] widgets.
......@@ -518,12 +563,16 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
/// 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.
///
/// If [indicator] is specified, this property is ignored.
final Color indicatorColor;
/// The thickness of the line that appears below the selected tab. The value
/// of this parameter must be greater than zero.
///
/// The default value of [indicatorWeight] is 2.0.
///
/// If [indicator] is specified, this property is ignored.
final double indicatorWeight;
/// The horizontal padding for the line that appears below the selected tab.
......@@ -535,8 +584,37 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
/// [indicatorPadding] are ignored.
///
/// The default value of [indicatorPadding] is [EdgeInsets.zero].
///
/// If [indicator] is specified, this property is ignored.
final EdgeInsetsGeometry indicatorPadding;
/// Defines the appearance of the selected tab indicator.
///
/// If [indicator] is specified, the [indicatorColor], [indicatorWeight],
/// and [indicatorPadding] properties are ignored.
///
/// The default, underline-style, selected tab indicator can be defined with
/// [UnderlineTabIndicator].
///
/// The indicator's size is based on the tab's bounds. If [indicatorSize]
/// is [TabBarIndicatorSize.tab] the tab's bounds are as wide as the space
/// occupied by the tab in the tab bar. If [indicatorSize] is
/// [TabBarIndicatorSize.label] then the tab's bounds are only as wide as
/// the tab widget itself.
final Decoration indicator;
/// Defines how the selected tab indicator's size is computed.
///
/// The size of the selected tab indicator is defined relative to the
/// tab's overall bounds if [indicatorSize] is [TabBarIndicatorSize.tab]
/// (the default) or relative to the bounds of the tab's widget if
/// [indicatorSize] is [TabBarIndicatorSize.label].
///
/// The selected tab's location appearance can be refined further with
/// the [indicatorColor], [indicatorWeight], [indicatorPadding], and
/// [indicator] properties.
final TabBarIndicatorSize indicatorSize;
/// The color of selected tab labels.
///
/// Unselected tab labels are rendered with the same color rendered at 70%
......@@ -591,6 +669,39 @@ class _TabBarState extends State<TabBar> {
_IndicatorPainter _indicatorPainter;
int _currentIndex;
double _tabStripWidth;
List<GlobalKey> _tabKeys;
@override
void initState() {
super.initState();
// If indicatorSize is TabIndicatorSize.label, _tabKeys[i] is used to find
// the width of tab widget i. See _IndicatorPainter.indicatorRect().
_tabKeys = widget.tabs.map((Widget tab) => new GlobalKey()).toList();
}
Decoration get _indicator {
if (widget.indicator != null)
return widget.indicator;
Color color = widget.indicatorColor ?? Theme.of(context).indicatorColor;
// ThemeData tries to avoid this by having indicatorColor avoid being the
// primaryColor. However, it's possible that the tab bar is on a
// Material that isn't the primaryColor. In that case, if the indicator
// color ends up matching the material's color, then this overrides it.
// When that happens, automatic transitions of the theme will likely look
// ugly as the indicator color suddenly snaps to white at one end, but it's
// not clear how to avoid that any further.
if (color == Material.of(context).color)
color = Colors.white;
return new UnderlineTabIndicator(
insets: widget.indicatorPadding,
borderSide: new BorderSide(
width: widget.indicatorWeight,
color: color,
),
);
}
void _updateTabController() {
final TabController newController = widget.controller ?? DefaultTabController.of(context);
......@@ -618,19 +729,24 @@ class _TabBarState extends State<TabBar> {
_controller.animation.addListener(_handleTabControllerAnimationTick);
_controller.addListener(_handleTabControllerTick);
_currentIndex = _controller.index;
_indicatorPainter = new _IndicatorPainter(
controller: _controller,
indicatorWeight: widget.indicatorWeight,
indicatorPadding: widget.indicatorPadding,
old: _indicatorPainter,
);
}
}
void _initIndicatorPainter() {
_indicatorPainter = _controller == null ? null : new _IndicatorPainter(
controller: _controller,
indicator: _indicator,
indicatorSize: widget.indicatorSize,
tabKeys: _tabKeys,
old: _indicatorPainter,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updateTabController();
_initIndicatorPainter();
}
@override
......@@ -638,10 +754,24 @@ class _TabBarState extends State<TabBar> {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller)
_updateTabController();
if (widget.indicatorColor != oldWidget.indicatorColor ||
widget.indicatorWeight != oldWidget.indicatorWeight ||
widget.indicatorSize != oldWidget.indicatorSize ||
widget.indicator != oldWidget.indicator)
_initIndicatorPainter();
if (widget.tabs.length > oldWidget.tabs.length) {
final int delta = widget.tabs.length - oldWidget.tabs.length;
_tabKeys.addAll(new List<GlobalKey>.generate(delta, (int n) => new GlobalKey()));
} else if (widget.tabs.length < oldWidget.tabs.length) {
_tabKeys.removeRange(widget.tabs.length, oldWidget.tabs.length);
}
}
@override
void dispose() {
_indicatorPainter.dispose();
if (_controller != null) {
_controller.animation.removeListener(_handleTabControllerAnimationTick);
_controller.removeListener(_handleTabControllerTick);
......@@ -755,24 +885,25 @@ class _TabBarState extends State<TabBar> {
);
}
final List<Widget> wrappedTabs = new List<Widget>.from(widget.tabs, growable: false);
final List<Widget> wrappedTabs = new List<Widget>(widget.tabs.length);
for (int i = 0; i < widget.tabs.length; i += 1) {
wrappedTabs[i] = new Center(
heightFactor: 1.0,
child: new Padding(
padding: kTabLabelPadding,
child: new KeyedSubtree(
key: _tabKeys[i],
child: widget.tabs[i],
),
),
);
}
// If the controller was provided by DefaultTabController and we're part
// of a Hero (typically the AppBar), then we will not be able to find the
// controller during a Hero transition. See https://github.com/flutter/flutter/issues/213.
if (_controller != null) {
_indicatorPainter._color = widget.indicatorColor ?? Theme.of(context).indicatorColor;
if (_indicatorPainter._color == Material.of(context).color) {
// ThemeData tries to avoid this by having indicatorColor avoid being the
// primaryColor. However, it's possible that the tab bar is on a
// Material that isn't the primaryColor. In that case, if the indicator
// color ends up clashing, then this overrides it. When that happens,
// automatic transitions of the theme will likely look ugly as the
// indicator color suddenly snaps to white at one end, but it's not clear
// how to avoid that any further.
_indicatorPainter._color = Colors.white;
}
final int previousIndex = _controller.previousIndex;
if (_controller.indexIsChanging) {
......@@ -799,9 +930,9 @@ class _TabBarState extends State<TabBar> {
}
}
// Add the tap handler to each tab. If the tab bar is scrollable
// then give all of the tabs equal flexibility so that their widths
// reflect the intrinsic width of their labels.
// Add the tap handler to each tab. If the tab bar is not scrollable
// then give all of the tabs equal flexibility so that they each occupy
// the same share of the tab bar's overall width.
final int tabCount = widget.tabs.length;
for (int index = 0; index < tabCount; index += 1) {
wrappedTabs[index] = new InkWell(
......
......@@ -188,7 +188,7 @@ abstract class Decoration extends Diagnosticable {
abstract class BoxPainter {
/// Abstract const constructor. This constructor enables subclasses to provide
/// const constructors so that they can be used in const expressions.
BoxPainter([this.onChanged]);
const BoxPainter([this.onChanged]);
/// Paints the [Decoration] for which this object was created on the
/// given canvas using the given configuration.
......
......@@ -140,9 +140,11 @@ class TabIndicatorRecordingCanvas extends TestRecordingCanvas {
Rect indicatorRect;
@override
void drawRect(Rect rect, Paint paint) {
void drawLine(Offset p1, Offset p2, Paint paint) {
// Assuming that the indicatorWeight is 2.0, the default.
const double indicatorWeight = 2.0;
if (paint.color == indicatorColor)
indicatorRect = rect;
indicatorRect = new Rect.fromPoints(p1, p2).inflate(indicatorWeight / 2.0);
}
}
......@@ -926,21 +928,22 @@ void main() {
// The initialIndex tab should be visible and right justified
expect(find.text('TAB #19'), findsOneWidget);
expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB #19')).dx, 800.0);
// Tabs have a minimum width of 72.0 and 'TAB #19' is wider than
// that. Tabs are padded horizontally with kTabLabelPadding.
final double tabRight = 800.0 - kTabLabelPadding.right;
expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB #19')).dx, tabRight);
});
testWidgets('TabBar with indicatorWeight, indicatorPadding (LTR)', (WidgetTester tester) async {
const Color color = const Color(0xFF00FF00);
const double height = 100.0;
const double weight = 8.0;
const Color indicatorColor = const Color(0xFF00FF00);
const double indicatorWeight = 8.0;
const double padLeft = 8.0;
const double padRight = 4.0;
final List<Widget> tabs = new List<Widget>.generate(4, (int index) {
return new Container(
key: new ValueKey<int>(index),
height: height,
);
return new Tab(text: 'Tab $index');
});
final TabController controller = new TabController(
......@@ -950,61 +953,56 @@ void main() {
await tester.pumpWidget(
boilerplate(
child: new Column(
children: <Widget>[
new TabBar(
indicatorWeight: 8.0,
indicatorColor: color,
indicatorPadding: const EdgeInsets.only(left: padLeft, right: padRight),
controller: controller,
tabs: tabs,
),
new Flexible(child: new Container()),
],
child: new Container(
alignment: Alignment.topLeft,
child: new TabBar(
indicatorWeight: indicatorWeight,
indicatorColor: indicatorColor,
indicatorPadding: const EdgeInsets.only(left: padLeft, right: padRight),
controller: controller,
tabs: tabs,
),
),
),
);
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
expect(tabBarBox.size.height, 54.0); // 54 = _kTabHeight(46) + indicatorWeight(8.0)
// Selected tab dimensions
double tabWidth = tester.getSize(find.byKey(const ValueKey<int>(0))).width;
double tabLeft = tester.getTopLeft(find.byKey(const ValueKey<int>(0))).dx;
double tabRight = tabLeft + tabWidth;
final double indicatorY = 54.0 - indicatorWeight / 2.0;
double indicatorLeft = padLeft + indicatorWeight / 2.0;
double indicatorRight = 200.0 - (padRight + indicatorWeight / 2.0);
expect(tabBarBox, paints..rect(
style: PaintingStyle.fill,
color: color,
rect: new Rect.fromLTRB(tabLeft + padLeft, height, tabRight - padRight, height + weight)
expect(tabBarBox, paints..line(
color: indicatorColor,
strokeWidth: indicatorWeight,
p1: new Offset(indicatorLeft, indicatorY),
p2: new Offset(indicatorRight, indicatorY),
));
// Select tab 3
controller.index = 3;
await tester.pumpAndSettle();
tabWidth = tester.getSize(find.byKey(const ValueKey<int>(3))).width;
tabLeft = tester.getTopLeft(find.byKey(const ValueKey<int>(3))).dx;
tabRight = tabLeft + tabWidth;
indicatorLeft = 600.0 + padLeft + indicatorWeight / 2.0;
indicatorRight = 800.0 - (padRight + indicatorWeight / 2.0);
expect(tabBarBox, paints..rect(
style: PaintingStyle.fill,
color: color,
rect: new Rect.fromLTRB(tabLeft + padLeft, height, tabRight - padRight, height + weight)
expect(tabBarBox, paints..line(
color: indicatorColor,
strokeWidth: indicatorWeight,
p1: new Offset(indicatorLeft, indicatorY),
p2: new Offset(indicatorRight, indicatorY),
));
});
testWidgets('TabBar with indicatorWeight, indicatorPadding (RTL)', (WidgetTester tester) async {
const Color color = const Color(0xFF00FF00);
const double height = 100.0;
const double weight = 8.0;
const Color indicatorColor = const Color(0xFF00FF00);
const double indicatorWeight = 8.0;
const double padLeft = 8.0;
const double padRight = 4.0;
final List<Widget> tabs = new List<Widget>.generate(4, (int index) {
return new Container(
key: new ValueKey<int>(index),
height: height,
);
return new Tab(text: 'Tab $index');
});
final TabController controller = new TabController(
......@@ -1015,46 +1013,113 @@ void main() {
await tester.pumpWidget(
boilerplate(
textDirection: TextDirection.rtl,
child: new Column(
children: <Widget>[
new TabBar(
indicatorWeight: 8.0,
indicatorColor: color,
indicatorPadding: const EdgeInsets.only(left: padLeft, right: padRight),
controller: controller,
tabs: tabs,
),
new Flexible(child: new Container()),
],
child: new Container(
alignment: Alignment.topLeft,
child: new TabBar(
indicatorWeight: indicatorWeight,
indicatorColor: indicatorColor,
indicatorPadding: const EdgeInsets.only(left: padLeft, right: padRight),
controller: controller,
tabs: tabs,
),
),
),
);
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
// Selected tab dimensions
double tabWidth = tester.getSize(find.byKey(const ValueKey<int>(0))).width;
double tabLeft = tester.getTopLeft(find.byKey(const ValueKey<int>(0))).dx;
double tabRight = tabLeft + tabWidth;
expect(tabBarBox, paints..rect(
style: PaintingStyle.fill,
color: color,
rect: new Rect.fromLTRB(tabLeft + padLeft, height, tabRight - padRight, height + weight)
expect(tabBarBox.size.height, 54.0); // 54 = _kTabHeight(46) + indicatorWeight(8.0)
expect(tabBarBox.size.width, 800.0);
final double indicatorY = 54.0 - indicatorWeight / 2.0;
double indicatorLeft = 600.0 + padLeft + indicatorWeight / 2.0;
double indicatorRight = 800.0 - padRight - indicatorWeight / 2.0;
expect(tabBarBox, paints..line(
color: indicatorColor,
strokeWidth: indicatorWeight,
p1: new Offset(indicatorLeft, indicatorY),
p2: new Offset(indicatorRight, indicatorY),
));
// Select tab 3
controller.index = 3;
await tester.pumpAndSettle();
tabWidth = tester.getSize(find.byKey(const ValueKey<int>(3))).width;
tabLeft = tester.getTopLeft(find.byKey(const ValueKey<int>(3))).dx;
tabRight = tabLeft + tabWidth;
indicatorLeft = padLeft + indicatorWeight / 2.0;
indicatorRight = 200.0 - padRight - indicatorWeight / 2.0;
expect(tabBarBox, paints..line(
color: indicatorColor,
strokeWidth: indicatorWeight,
p1: new Offset(indicatorLeft, indicatorY),
p2: new Offset(indicatorRight, indicatorY),
));
});
testWidgets('TabBar changes indicator attributes', (WidgetTester tester) async {
final List<Widget> tabs = new List<Widget>.generate(4, (int index) {
return new Tab(text: 'Tab $index');
});
final TabController controller = new TabController(
vsync: const TestVSync(),
length: tabs.length,
);
Color indicatorColor = const Color(0xFF00FF00);
double indicatorWeight = 8.0;
double padLeft = 8.0;
double padRight = 4.0;
Widget buildFrame() {
return boilerplate(
child: new Container(
alignment: Alignment.topLeft,
child: new TabBar(
indicatorWeight: indicatorWeight,
indicatorColor: indicatorColor,
indicatorPadding: new EdgeInsets.only(left: padLeft, right: padRight),
controller: controller,
tabs: tabs,
),
),
);
}
await tester.pumpWidget(buildFrame());
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
expect(tabBarBox.size.height, 54.0); // 54 = _kTabHeight(46) + indicatorWeight(8.0)
expect(tabBarBox, paints..rect(
style: PaintingStyle.fill,
color: color,
rect: new Rect.fromLTRB(tabLeft + padLeft, height, tabRight - padRight, height + weight)
double indicatorY = 54.0 - indicatorWeight / 2.0;
double indicatorLeft = padLeft + indicatorWeight / 2.0;
double indicatorRight = 200.0 - (padRight + indicatorWeight / 2.0);
expect(tabBarBox, paints..line(
color: indicatorColor,
strokeWidth: indicatorWeight,
p1: new Offset(indicatorLeft, indicatorY),
p2: new Offset(indicatorRight, indicatorY),
));
indicatorColor = const Color(0xFF0000FF);
indicatorWeight = 4.0;
padLeft = 4.0;
padRight = 8.0;
await tester.pumpWidget(buildFrame());
expect(tabBarBox.size.height, 50.0); // 54 = _kTabHeight(46) + indicatorWeight(4.0)
indicatorY = 50.0 - indicatorWeight / 2.0;
indicatorLeft = padLeft + indicatorWeight / 2.0;
indicatorRight = 200.0 - (padRight + indicatorWeight / 2.0);
expect(tabBarBox, paints..line(
color: indicatorColor,
strokeWidth: indicatorWeight,
p1: new Offset(indicatorLeft, indicatorY),
p2: new Offset(indicatorRight, indicatorY),
));
});
......@@ -1065,6 +1130,8 @@ void main() {
new SizedBox(key: new UniqueKey(), width: 150.0, height: 50.0),
];
const double indicatorWeight = 2.0; // the default
final TabController controller = new TabController(
vsync: const TestVSync(),
length: tabs.length,
......@@ -1072,27 +1139,56 @@ void main() {
await tester.pumpWidget(
boilerplate(
child: new Center(
child: new SizedBox(
width: 800.0,
child: new TabBar(
indicatorPadding: const EdgeInsetsDirectional.only(start: 100.0),
isScrollable: true,
controller: controller,
tabs: tabs,
),
child: new Container(
alignment: Alignment.topLeft,
child: new TabBar(
indicatorPadding: const EdgeInsetsDirectional.only(start: 100.0),
isScrollable: true,
controller: controller,
tabs: tabs,
),
),
),
);
expect(tester.getRect(find.byKey(tabs[0].key)), new Rect.fromLTRB(0.0, 284.0, 130.0, 314.0));
expect(tester.getRect(find.byKey(tabs[1].key)), new Rect.fromLTRB(130.0, 279.0, 270.0, 319.0));
expect(tester.getRect(find.byKey(tabs[2].key)), new Rect.fromLTRB(270.0, 274.0, 420.0, 324.0));
expect(tester.firstRenderObject<RenderBox>(find.byType(TabBar)), paints..rect(
style: PaintingStyle.fill,
rect: new Rect.fromLTRB(100.0, 50.0, 130.0, 52.0),
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
const double tabBarHeight = 50.0 + indicatorWeight; // 50 = max tab height
expect(tabBarBox.size.height, tabBarHeight);
// Tab0 width = 130, height = 30
double tabLeft = kTabLabelPadding.left;
double tabRight = tabLeft + 130.0;
double tabTop = (tabBarHeight - indicatorWeight - 30.0) / 2.0;
double tabBottom = tabTop + 30.0;
Rect tabRect = new Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom);
expect(tester.getRect(find.byKey(tabs[0].key)), tabRect);
// Tab1 width = 140, height = 40
tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left;
tabRight = tabLeft + 140.0;
tabTop = (tabBarHeight - indicatorWeight - 40.0) / 2.0;
tabBottom = tabTop + 40.0;
tabRect = new Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom);
expect(tester.getRect(find.byKey(tabs[1].key)), tabRect);
// Tab2 width = 150, height = 50
tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left;
tabRight = tabLeft + 150.0;
tabTop = (tabBarHeight - indicatorWeight - 50.0) / 2.0;
tabBottom = tabTop + 50.0;
tabRect = new Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom);
expect(tester.getRect(find.byKey(tabs[2].key)), tabRect);
// Tab 0 selected, indicator padding resolves to left: 100.0
final double indicatorLeft = 100.0 + indicatorWeight / 2.0;
final double indicatorRight = 130.0 + kTabLabelPadding.horizontal - indicatorWeight / 2.0;
final double indicatorY = tabBottom + indicatorWeight / 2.0;
expect(tabBarBox, paints..line(
strokeWidth: indicatorWeight,
p1: new Offset(indicatorLeft, indicatorY),
p2: new Offset(indicatorRight, indicatorY),
));
});
......@@ -1103,6 +1199,8 @@ void main() {
new SizedBox(key: new UniqueKey(), width: 150.0, height: 50.0),
];
const double indicatorWeight = 2.0; // the default
final TabController controller = new TabController(
vsync: const TestVSync(),
length: tabs.length,
......@@ -1111,36 +1209,62 @@ void main() {
await tester.pumpWidget(
boilerplate(
textDirection: TextDirection.rtl,
child: new Center(
child: new SizedBox(
width: 800.0,
child: new TabBar(
indicatorPadding: const EdgeInsetsDirectional.only(start: 100.0),
isScrollable: true,
controller: controller,
tabs: tabs,
),
child: new Container(
alignment: Alignment.topLeft,
child: new TabBar(
indicatorPadding: const EdgeInsetsDirectional.only(start: 100.0),
isScrollable: true,
controller: controller,
tabs: tabs,
),
),
),
);
expect(tester.getRect(find.byKey(tabs[0].key)), new Rect.fromLTRB(670.0, 284.0, 800.0, 314.0));
expect(tester.getRect(find.byKey(tabs[1].key)), new Rect.fromLTRB(530.0, 279.0, 670.0, 319.0));
expect(tester.getRect(find.byKey(tabs[2].key)), new Rect.fromLTRB(380.0, 274.0, 530.0, 324.0));
final RenderBox tabBar = tester.renderObject<RenderBox>(find.byType(CustomPaint).at(1));
expect(tabBar.size, const Size(420.0, 52.0));
expect(tabBar, paints..rect(
style: PaintingStyle.fill,
rect: new Rect.fromLTRB(tabBar.size.width - 130.0, 50.0, tabBar.size.width - 100.0, 52.0),
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
const double tabBarHeight = 50.0 + indicatorWeight; // 50 = max tab height
expect(tabBarBox.size.height, tabBarHeight);
// Tab2 width = 150, height = 50
double tabLeft = kTabLabelPadding.left;
double tabRight = tabLeft + 150.0;
double tabTop = (tabBarHeight - indicatorWeight - 50.0) / 2.0;
double tabBottom = tabTop + 50.0;
Rect tabRect = new Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom);
expect(tester.getRect(find.byKey(tabs[2].key)), tabRect);
// Tab1 width = 140, height = 40
tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left;
tabRight = tabLeft + 140.0;
tabTop = (tabBarHeight - indicatorWeight - 40.0) / 2.0;
tabBottom = tabTop + 40.0;
tabRect = new Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom);
expect(tester.getRect(find.byKey(tabs[1].key)), tabRect);
// Tab0 width = 130, height = 30
tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left;
tabRight = tabLeft + 130.0;
tabTop = (tabBarHeight - indicatorWeight - 30.0) / 2.0;
tabBottom = tabTop + 30.0;
tabRect = new Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom);
expect(tester.getRect(find.byKey(tabs[0].key)), tabRect);
// Tab 0 selected, indicator padding resolves to right: 100.0
final double indicatorLeft = tabLeft - kTabLabelPadding.left + indicatorWeight / 2.0;
final double indicatorRight = tabRight + kTabLabelPadding.left - indicatorWeight / 2.0 - 100.0;
final double indicatorY = 50.0 + indicatorWeight / 2.0;
expect(tabBarBox, paints..line(
strokeWidth: indicatorWeight,
p1: new Offset(indicatorLeft, indicatorY),
p2: new Offset(indicatorRight, indicatorY),
));
});
testWidgets('Overflowing RTL tab bar', (WidgetTester tester) async {
final List<Widget> tabs = new List<Widget>.filled(100,
new SizedBox(key: new UniqueKey(), width: 30.0, height: 20.0),
// For convenience padded width of each tab will equal 100:
// 76 + kTabLabelPadding.horizontal(24)
new SizedBox(key: new UniqueKey(), width: 76.0, height: 40.0),
);
final TabController controller = new TabController(
......@@ -1148,10 +1272,13 @@ void main() {
length: tabs.length,
);
const double indicatorWeight = 2.0; // the default
await tester.pumpWidget(
boilerplate(
textDirection: TextDirection.rtl,
child: new Center(
child: new Container(
alignment: Alignment.topLeft,
child: new TabBar(
isScrollable: true,
controller: controller,
......@@ -1161,25 +1288,40 @@ void main() {
),
);
expect(tester.firstRenderObject<RenderBox>(find.byType(TabBar)), paints..rect(
style: PaintingStyle.fill,
rect: new Rect.fromLTRB(2970.0, 20.0, 3000.0, 22.0),
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
const double tabBarHeight = 40.0 + indicatorWeight; // 40 = tab height
expect(tabBarBox.size.height, tabBarHeight);
// Tab 0 out of 100 selected
double indicatorLeft = 99.0 * 100.0 + indicatorWeight / 2.0;
double indicatorRight = 100.0 * 100.0 - indicatorWeight / 2.0;
final double indicatorY = 40.0 + indicatorWeight / 2.0;
expect(tabBarBox, paints..line(
strokeWidth: indicatorWeight,
p1: new Offset(indicatorLeft, indicatorY),
p2: new Offset(indicatorRight, indicatorY),
));
controller.animateTo(tabs.length - 1, duration: const Duration(seconds: 1), curve: Curves.linear);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(tester.firstRenderObject<RenderBox>(find.byType(TabBar)), paints..rect(
style: PaintingStyle.fill,
rect: new Rect.fromLTRB(742.5, 20.0, 772.5, 22.0), // (these values were derived empirically, not analytically)
// The x coordinates of p1 and p2 were derived empirically, not analytically.
expect(tabBarBox, paints..line(
strokeWidth: indicatorWeight,
p1: new Offset(2476.0, indicatorY),
p2: new Offset(2574.0, indicatorY),
));
await tester.pump(const Duration(milliseconds: 501));
expect(tester.firstRenderObject<RenderBox>(find.byType(TabBar)), paints..rect(
style: PaintingStyle.fill,
rect: new Rect.fromLTRB(0.0, 20.0, 30.0, 22.0),
// Tab 99 out of 100 selected, appears on the far left because RTL
indicatorLeft = indicatorWeight / 2.0;
indicatorRight = 100.0 - indicatorWeight / 2.0;
expect(tabBarBox, paints..line(
strokeWidth: indicatorWeight,
p1: new Offset(indicatorLeft, indicatorY),
p2: new Offset(indicatorRight, indicatorY),
));
});
......@@ -1372,22 +1514,23 @@ void main() {
expect(tester.getSize(find.byType(TabBar)), const Size(800.0, 48.0));
expect(tester.getSize(find.byType(TabBarView)), const Size(800.0, 600.0 - 48.0));
// The one tab spans the app's width
expect(tester.getTopLeft(find.widgetWithText(Tab, 'TAB')).dx, 0);
expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB')).dx, 800);
// The one tab should be center vis the app's width (800).
final double tabLeft = tester.getTopLeft(find.widgetWithText(Tab, 'TAB')).dx;
final double tabRight = tester.getTopRight(find.widgetWithText(Tab, 'TAB')).dx;
expect(tabLeft + (tabRight - tabLeft) / 2.0, 400.0);
// A fling in the TabBar or TabBarView, shouldn't move the tab.
await tester.fling(find.byType(TabBar), const Offset(-100.0, 0.0), 5000.0);
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getTopLeft(find.widgetWithText(Tab, 'TAB')).dx, 0);
expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB')).dx, 800);
expect(tester.getTopLeft(find.widgetWithText(Tab, 'TAB')).dx, tabLeft);
expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB')).dx, tabRight);
await tester.pumpAndSettle();
await tester.fling(find.byType(TabBarView), const Offset(100.0, 0.0), 5000.0);
await tester.pump(const Duration(milliseconds: 50));
expect(tester.getTopLeft(find.widgetWithText(Tab, 'TAB')).dx, 0);
expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB')).dx, 800);
expect(tester.getTopLeft(find.widgetWithText(Tab, 'TAB')).dx, tabLeft);
expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB')).dx, tabRight);
await tester.pumpAndSettle();
expect(controller.index, 0);
......
......@@ -270,8 +270,8 @@ abstract class PaintPattern {
/// Indicates that a line is expected next.
///
/// The next line is examined. Any arguments that are passed to this method
/// are compared to the actual [Canvas.drawLine] call's `paint` argument, and
/// any mismatches result in failure.
/// are compared to the actual [Canvas.drawLine] call's `p1`, `p2`, and
/// `paint` arguments, and any mismatches result in failure.
///
/// If no call to [Canvas.drawLine] was made, then this results in failure.
///
......@@ -283,7 +283,7 @@ abstract class PaintPattern {
/// painting has completed, not at the time of the call. If the same [Paint]
/// object is reused multiple times, then this may not match the actual
/// arguments as they were seen by the method.
void line({ Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style });
void line({ Offset p1, Offset p2, Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style });
/// Indicates that an arc is expected next.
///
......@@ -690,8 +690,8 @@ class _TestRecordingCanvasPatternMatcher extends _TestRecordingCanvasMatcher imp
}
@override
void line({ Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style }) {
_predicates.add(new _LinePaintPredicate(color: color, strokeWidth: strokeWidth, hasMaskFilter: hasMaskFilter, style: style));
void line({ Offset p1, Offset p2, Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style }) {
_predicates.add(new _LinePaintPredicate(p1: p1, p2: p2, color: color, strokeWidth: strokeWidth, hasMaskFilter: hasMaskFilter, style: style));
}
@override
......@@ -1073,11 +1073,38 @@ class _PathPaintPredicate extends _DrawCommandPaintPredicate {
}
}
// TODO(ianh): add arguments to test the points, length, angle, that kind of thing
// TODO(ianh): add arguments to test the length, angle, that kind of thing
class _LinePaintPredicate extends _DrawCommandPaintPredicate {
_LinePaintPredicate({ Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style }) : super(
_LinePaintPredicate({ this.p1, this.p2, Color color, double strokeWidth, bool hasMaskFilter, PaintingStyle style }) : super(
#drawLine, 'a line', 3, 2, color: color, strokeWidth: strokeWidth, hasMaskFilter: hasMaskFilter, style: style
);
final Offset p1;
final Offset p2;
@override
void verifyArguments(List<dynamic> arguments) {
super.verifyArguments(arguments); // Checks the 3rd argument, a Paint
if (arguments.length != 3)
throw 'It called $methodName with ${arguments.length} arguments; expected 3.';
final Offset p1Argument = arguments[0];
final Offset p2Argument = arguments[1];
if (p1 != null && p1Argument != p1) {
throw 'It called $methodName with p1 endpoint, $p1Argument, which was not exactly the expected endpoint ($p1).';
}
if (p2 != null && p2Argument != p2) {
throw 'It called $methodName with p2 endpoint, $p2Argument, which was not exactly the expected endpoint ($p2).';
}
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
if (p1 != null)
description.add('end point p1: $p1');
if (p2 != null)
description.add('end point p2: $p2');
}
}
class _ArcPaintPredicate extends _DrawCommandPaintPredicate {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment