From 123e9e013dce74b2c5270f124b46cc8c65edcbfb Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Mon, 12 Jun 2017 15:44:33 -0700 Subject: [PATCH] Allow TabBars, TabBarViews, TabControllers, with zero or one tabs (#10608) --- .../lib/src/material/tab_controller.dart | 37 +++++--- packages/flutter/lib/src/material/tabs.dart | 28 ++++-- packages/flutter/test/material/tabs_test.dart | 95 +++++++++++++++++++ 3 files changed, 139 insertions(+), 21 deletions(-) diff --git a/packages/flutter/lib/src/material/tab_controller.dart b/packages/flutter/lib/src/material/tab_controller.dart index ebe89d4082..7508357265 100644 --- a/packages/flutter/lib/src/material/tab_controller.dart +++ b/packages/flutter/lib/src/material/tab_controller.dart @@ -16,6 +16,8 @@ import 'constants.dart'; /// A stateful widget that builds a [TabBar] or a [TabBarView] can create /// a TabController and share it directly. /// +/// ## Sample code +/// /// ```dart /// class _MyDemoState extends State with SingleTickerProviderStateMixin { /// final List myTabs = [ @@ -62,12 +64,18 @@ import 'constants.dart'; /// inherited widget. class TabController extends ChangeNotifier { /// Creates an object that manages the state required by [TabBar] and a [TabBarView]. + /// + /// The [length] cannot be null or negative. Typically its a value greater than one, i.e. + /// typically there are two or more tabs. + /// + /// The `initialIndex` must be valid given [length] and cannot be null. If [length] is + /// zero, then `initialIndex` must be 0 (the default). TabController({ int initialIndex: 0, @required this.length, @required TickerProvider vsync }) - : assert(length != null && length > 1), - assert(initialIndex != null && initialIndex >= 0 && initialIndex < length), + : assert(length != null && length >= 0), + assert(initialIndex != null && initialIndex >= 0 && (length == 0 || initialIndex < length)), _index = initialIndex, _previousIndex = initialIndex, - _animationController = new AnimationController( + _animationController = length < 2 ? null : new AnimationController( value: initialIndex.toDouble(), upperBound: (length - 1).toDouble(), vsync: vsync @@ -81,18 +89,21 @@ class TabController extends ChangeNotifier { /// selected tab is changed, the animation's value equals [index]. The /// animation's value can be [offset] by +/- 1.0 to reflect [TabBarView] /// drag scrolling. - Animation get animation => _animationController.view; + /// + /// If length is zero or one, [index] animations don't happen and the value + /// of this property is [kAlwaysCompleteAnimation]. + Animation get animation => _animationController?.view ?? kAlwaysCompleteAnimation; final AnimationController _animationController; - /// The total number of tabs. Must be greater than one. + /// The total number of tabs. Typically greater than one. final int length; void _changeIndex(int value, { Duration duration, Curve curve }) { assert(value != null); - assert(value >= 0 && value < length); + assert(value >= 0 && (value < length || length == 0)); assert(duration == null ? curve == null : true); assert(_indexIsChangingCount >= 0); - if (value == _index) + if (value == _index || length < 2) return; _previousIndex = index; _index = value; @@ -118,6 +129,9 @@ class TabController extends ChangeNotifier { /// [indexIsChanging] to false, and notifies listeners. /// /// To change the currently selected tab and play the [animation] use [animateTo]. + /// + /// The value of [index] must be valid given [length]. If [length] is zero, + /// then [index] will also be zero. int get index => _index; int _index; set index(int value) { @@ -148,8 +162,9 @@ class TabController extends ChangeNotifier { /// drags left or right. A value between -1.0 and 0.0 implies that the /// TabBarView has been dragged to the left. Similarly a value between /// 0.0 and 1.0 implies that the TabBarView has been dragged to the right. - double get offset => _animationController.value - _index.toDouble(); + double get offset => length > 1 ? _animationController.value - _index.toDouble() : 0.0; set offset(double value) { + assert(length > 1); assert(value != null); assert(value >= -1.0 && value <= 1.0); assert(!indexIsChanging); @@ -160,7 +175,7 @@ class TabController extends ChangeNotifier { @override void dispose() { - _animationController.dispose(); + _animationController?.dispose(); super.dispose(); } } @@ -220,7 +235,7 @@ class _TabControllerScope extends InheritedWidget { class DefaultTabController extends StatefulWidget { /// Creates a default tab controller for the given [child] widget. /// - /// The [length] argument must be great than one. + /// The [length] argument is typically greater than one. /// /// The [initialIndex] argument must not be null. const DefaultTabController({ @@ -231,7 +246,7 @@ class DefaultTabController extends StatefulWidget { }) : assert(initialIndex != null), super(key: key); - /// The total number of tabs. Must be greater than one. + /// The total number of tabs. Typically greater than one. final int length; /// The initial index of the selected tab. diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index a67bf0509c..2a56826c49 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -389,22 +389,23 @@ class _TabBarScrollController extends ScrollController { /// A material design widget that displays a horizontal row of tabs. /// -/// Typically created as part of an [AppBar] and in conjuction with a -/// [TabBarView]. +/// Typically created as the [AppBar.bottom] part of an [AppBar] and in +/// conjuction with a [TabBarView]. /// /// If a [TabController] is not provided, then there must be a -/// [DefaultTabController] ancestor. +/// [DefaultTabController] ancestor. The tab controller's [TabController.length] +/// must equal the length of the [tabs] list. /// /// Requires one of its ancestors to be a [Material] widget. /// /// See also: /// -/// * [TabBarView], which displays the contents that the tab bar is selecting -/// between. +/// * [TabBarView], which displays page views that correspond to each tab. class TabBar extends StatefulWidget implements PreferredSizeWidget { /// Creates a material design tab bar. /// - /// The [tabs] argument must not be null and must have more than one widget. + /// The [tabs] argument cannot be null and its length must match the [controller]'s + /// [TabController.length]. /// /// If a [TabController] is not provided, then there must be a /// [DefaultTabController] ancestor. @@ -424,13 +425,15 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.labelStyle, this.unselectedLabelColor, this.unselectedLabelStyle, - }) : assert(tabs != null && tabs.length > 1), + }) : assert(tabs != null), assert(isScrollable != null), assert(indicatorWeight != null && indicatorWeight > 0.0), assert(indicatorPadding != null), super(key: key); - /// Typically a list of [Tab] widgets. + /// Typically a list of two or more [Tab] widgets. + /// + /// The length of this list must match the [controller]'s [TabController.length]. final List tabs; /// This widget's selection and animation state. @@ -667,6 +670,12 @@ class _TabBarState extends State { @override Widget build(BuildContext context) { + if (_controller.length == 0) { + return new Container( + height: _kTabHeight + widget.indicatorWeight, + ); + } + final List wrappedTabs = new List.from(widget.tabs, growable: false); // If the controller was provided by DefaultTabController and we're part @@ -774,8 +783,7 @@ class TabBarView extends StatefulWidget { Key key, @required this.children, this.controller, - }) : assert(children != null && children.length > 1), - super(key: key); + }) : assert(children != null), super(key: key); /// This widget's selection and animation state. /// diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index 988e25870c..4893b5f3ee 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -900,4 +900,99 @@ void main() { rect: new Rect.fromLTRB(tabLeft + padLeft, height, tabRight - padRight, height + weight) )); }); + + testWidgets('TabBar etc with zero tabs', (WidgetTester tester) async { + final TabController controller = new TabController( + vsync: const TestVSync(), + length: 0, + ); + + await tester.pumpWidget( + new Material( + child: new Column( + children: [ + new TabBar( + controller: controller, + tabs: const [], + ), + new Flexible( + child: new TabBarView( + controller: controller, + children: const [], + ), + ), + ], + ), + ), + ); + + expect(controller.index, 0); + 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)); + + // A fling in the TabBar or TabBarView, shouldn't do anything. + + await(tester.fling(find.byType(TabBar), const Offset(-100.0, 0.0), 5000.0)); + await(tester.pumpAndSettle()); + + await(tester.fling(find.byType(TabBarView), const Offset(100.0, 0.0), 5000.0)); + await(tester.pumpAndSettle()); + + expect(controller.index, 0); + }); + + testWidgets('TabBar etc with one tab', (WidgetTester tester) async { + final TabController controller = new TabController( + vsync: const TestVSync(), + length: 1, + ); + + await tester.pumpWidget( + new Material( + child: new Column( + children: [ + new TabBar( + controller: controller, + tabs: const [const Tab(text: 'TAB')], + ), + new Flexible( + child: new TabBarView( + controller: controller, + children: const [const Text('PAGE')], + ), + ), + ], + ), + ), + ); + + expect(controller.index, 0); + expect(find.text('TAB'), findsOneWidget); + expect(find.text('PAGE'), findsOneWidget); + 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); + + // 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); + 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); + await(tester.pumpAndSettle()); + + expect(controller.index, 0); + expect(find.text('TAB'), findsOneWidget); + expect(find.text('PAGE'), findsOneWidget); + }); + }