[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));
+  });
 }