[Material] Update TabController to support dynamic Tabs (#30884) * Update TabController to support dynamic tabs. * Added test for single Tab showing correct color.
diff --git a/packages/flutter/lib/src/material/tab_controller.dart b/packages/flutter/lib/src/material/tab_controller.dart index 07ff42a..f4813c3 100644 --- a/packages/flutter/lib/src/material/tab_controller.dart +++ b/packages/flutter/lib/src/material/tab_controller.dart
@@ -2,6 +2,8 @@ // 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; + import 'package:flutter/widgets.dart'; import 'constants.dart'; @@ -85,12 +87,40 @@ assert(initialIndex != null && initialIndex >= 0 && (length == 0 || initialIndex < length)), _index = initialIndex, _previousIndex = initialIndex, - _animationController = length < 2 ? null : AnimationController( + _animationController = AnimationController.unbounded( value: initialIndex.toDouble(), - upperBound: (length - 1).toDouble(), vsync: vsync, ); + // Private constructor used by `_copyWith`. This allows a new TabController to + // be created without having to create a new animationController. + TabController._({ + int index, + int previousIndex, + AnimationController animationController, + @required this.length, + }) : _index = index, + _previousIndex = previousIndex, + _animationController = animationController; + + + /// Creates a new [TabController] with `index`, `previousIndex`, and `length` + /// if they are non-null. + /// + /// This will reuse the existing [_animationController]. + /// + /// This is useful for [DefaultTabController], for example when + /// [DefaultTabController.length] is updated, this method is called so that a + /// new [TabController] is created without having to create a new [AnimationController]. + TabController _copyWith({ int index, int length, int previousIndex }) { + return TabController._( + index: index ?? _index, + length: length ?? this.length, + animationController: _animationController, + previousIndex: previousIndex ?? _previousIndex, + ); + } + /// An animation whose value represents the current position of the [TabBar]'s /// selected tab indicator as well as the scrollOffsets of the [TabBar] /// and [TabBarView]. @@ -178,9 +208,8 @@ /// 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 => length > 1 ? _animationController.value - _index.toDouble() : 0.0; + double get offset => _animationController.value - _index.toDouble(); set offset(double value) { - assert(length > 1); assert(value != null); assert(value >= -1.0 && value <= 1.0); assert(!indexIsChanging); @@ -262,6 +291,8 @@ this.initialIndex = 0, @required this.child, }) : assert(initialIndex != null), + assert(length >= 0), + assert(initialIndex >= 0 && initialIndex < length), super(key: key); /// The total number of tabs. Typically greater than one. Must match @@ -323,4 +354,24 @@ child: widget.child, ); } + + @override + void didUpdateWidget(DefaultTabController oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.length != widget.length) { + // If the length is shortened while the last tab is selected, we should + // automatically update the index of the controller to be the new last tab. + int newIndex; + int previousIndex = _controller.previousIndex; + if (_controller.index >= widget.length) { + newIndex = math.max(0, widget.length - 1); + previousIndex = _controller.index; + } + _controller = _controller._copyWith( + length: widget.length, + index: newIndex, + previousIndex: previousIndex, + ); + } + } }
diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index 1f98847..b481c2f 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart
@@ -783,16 +783,6 @@ return true; }()); - assert(() { - if (newController.length != widget.tabs.length) { - throw FlutterError( - 'Controller\'s length property (${newController.length}) does not match the \n' - 'number of tab elements (${widget.tabs.length}) present in TabBar\'s tabs property.' - ); - } - return true; - }()); - if (newController == _controller) return; @@ -960,6 +950,15 @@ @override Widget build(BuildContext context) { assert(debugCheckHasMaterialLocalizations(context)); + assert(() { + if (_controller.length != widget.tabs.length) { + throw FlutterError( + 'Controller\'s length property (${_controller.length}) does not match the \n' + 'number of tabs (${widget.tabs.length}) present in TabBar\'s tabs property.' + ); + } + return true; + }()); final MaterialLocalizations localizations = MaterialLocalizations.of(context); if (_controller.length == 0) { return Container( @@ -1144,16 +1143,6 @@ return true; }()); - assert(() { - if (newController.length != widget.children.length) { - throw FlutterError( - 'Controller\'s length property (${newController.length}) does not match the \n' - 'number of elements (${widget.children.length}) present in TabBarView\'s children property.' - ); - } - return true; - }()); - if (newController == _controller) return; @@ -1268,6 +1257,15 @@ @override Widget build(BuildContext context) { + assert(() { + if (_controller.length != widget.children.length) { + throw FlutterError( + 'Controller\'s length property (${_controller.length}) does not match the \n' + 'number of tabs (${widget.children.length}) present in TabBar\'s tabs property.' + ); + } + return true; + }()); return NotificationListener<ScrollNotification>( onNotification: _handleScrollNotification, child: PageView(
diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index 2ecefce..75880ce 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart
@@ -641,7 +641,7 @@ expect(tabController.previousIndex, 1); expect(tabController.indexIsChanging, false); expect(tabController.animation.value, 1.0); - expect(tabController.animation.status, AnimationStatus.completed); + expect(tabController.animation.status, AnimationStatus.forward); tabController.index = 0; await tester.pump(const Duration(milliseconds: 500)); @@ -2100,4 +2100,71 @@ expect(tester.hasRunningAnimations, isFalse); expect(await tester.pumpAndSettle(), 1); // no more frames are scheduled. }); + + // Regression test for https://github.com/flutter/flutter/issues/20292. + testWidgets('Number of tabs can be updated dynamically', (WidgetTester tester) async { + final List<String> threeTabs = <String>['A', 'B', 'C']; + final List<String> twoTabs = <String>['A', 'B']; + final List<String> oneTab = <String>['A']; + final Key key = UniqueKey(); + Widget buildTabs(List<String> tabs) { + return boilerplate( + child: DefaultTabController( + key: key, + length: tabs.length, + child: TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + ), + ), + ); + } + TabController getController() => DefaultTabController.of(tester.element(find.text('A'))); + + await tester.pumpWidget(buildTabs(threeTabs)); + await tester.tap(find.text('B')); + await tester.pump(); + TabController controller = getController(); + expect(controller.previousIndex, 0); + expect(controller.index, 1); + expect(controller.length, 3); + + await tester.pumpWidget(buildTabs(twoTabs)); + controller = getController(); + expect(controller.previousIndex, 0); + expect(controller.index, 1); + expect(controller.length, 2); + + await tester.pumpWidget(buildTabs(oneTab)); + controller = getController(); + expect(controller.previousIndex, 1); + expect(controller.index, 0); + expect(controller.length, 1); + + await tester.pumpWidget(buildTabs(twoTabs)); + controller = getController(); + expect(controller.previousIndex, 1); + expect(controller.index, 0); + expect(controller.length, 2); + }); + + // Regression test for https://github.com/flutter/flutter/issues/15008. + testWidgets('TabBar with one tab has correct color', (WidgetTester tester) async { + const Tab tab = Tab(text: 'A'); + const Color selectedTabColor = Color(1); + const Color unselectedTabColor = Color(2); + + await tester.pumpWidget(boilerplate( + child: const DefaultTabController( + length: 1, + child: TabBar( + tabs: <Tab>[tab], + labelColor: selectedTabColor, + unselectedLabelColor: unselectedTabColor, + ), + ), + )); + + final IconThemeData iconTheme = IconTheme.of(tester.element(find.text('A'))); + expect(iconTheme.color, equals(selectedTabColor)); + }); }