Support for replacing the TabController, after disposing the old one (#32434)
diff --git a/packages/flutter/lib/src/material/tab_controller.dart b/packages/flutter/lib/src/material/tab_controller.dart
index f4813c3..b8073a1 100644
--- a/packages/flutter/lib/src/material/tab_controller.dart
+++ b/packages/flutter/lib/src/material/tab_controller.dart
@@ -130,10 +130,9 @@
/// animation's value can be [offset] by +/- 1.0 to reflect [TabBarView]
/// drag scrolling.
///
- /// If length is zero or one, [index] animations don't happen and the value
- /// of this property is [kAlwaysCompleteAnimation].
- Animation<double> get animation => _animationController?.view ?? kAlwaysCompleteAnimation;
- final AnimationController _animationController;
+ /// If the TabController was disposed then return null.
+ Animation<double> get animation => _animationController?.view;
+ AnimationController _animationController;
/// The total number of tabs. Typically greater than one. Must match
/// [TabBar.tabs]'s and [TabBarView.children]'s length.
@@ -221,6 +220,7 @@
@override
void dispose() {
_animationController?.dispose();
+ _animationController = null;
super.dispose();
}
}
diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart
index c4b5e1f..aedd016 100644
--- a/packages/flutter/lib/src/material/tabs.dart
+++ b/packages/flutter/lib/src/material/tabs.dart
@@ -458,6 +458,18 @@
Animation<double> get parent => controller.animation;
@override
+ void removeStatusListener(AnimationStatusListener listener) {
+ if (controller.animation != null)
+ super.removeStatusListener(listener);
+ }
+
+ @override
+ void removeListener(VoidCallback listener) {
+ if (controller.animation != null)
+ super.removeListener(listener);
+ }
+
+ @override
double get value {
assert(!controller.indexIsChanging);
return (controller.animation.value - index.toDouble()).abs().clamp(0.0, 1.0);
@@ -768,6 +780,11 @@
);
}
+ // If the TabBar is rebuilt with a new tab controller, the caller should
+ // dispose the old one. In that case the old controller's animation will be
+ // null and should not be accessed.
+ bool get _controllerIsValid => _controller?.animation != null;
+
void _updateTabController() {
final TabController newController = widget.controller ?? DefaultTabController.of(context);
assert(() {
@@ -786,7 +803,7 @@
if (newController == _controller)
return;
- if (_controller != null) {
+ if (_controllerIsValid) {
_controller.animation.removeListener(_handleTabControllerAnimationTick);
_controller.removeListener(_handleTabControllerTick);
}
@@ -799,7 +816,7 @@
}
void _initIndicatorPainter() {
- _indicatorPainter = _controller == null ? null : _IndicatorPainter(
+ _indicatorPainter = !_controllerIsValid ? null : _IndicatorPainter(
controller: _controller,
indicator: _indicator,
indicatorSize: widget.indicatorSize ?? TabBarTheme.of(context).indicatorSize,
@@ -840,10 +857,11 @@
@override
void dispose() {
_indicatorPainter.dispose();
- if (_controller != null) {
+ if (_controllerIsValid) {
_controller.animation.removeListener(_handleTabControllerAnimationTick);
_controller.removeListener(_handleTabControllerTick);
}
+ _controller = null;
// We don't own the _controller Animation, so it's not disposed here.
super.dispose();
}
@@ -1129,6 +1147,11 @@
int _currentIndex;
int _warpUnderwayCount = 0;
+ // If the TabBarView is rebuilt with a new tab controller, the caller should
+ // dispose the old one. In that case the old controller's animation will be
+ // null and should not be accessed.
+ bool get _controllerIsValid => _controller?.animation != null;
+
void _updateTabController() {
final TabController newController = widget.controller ?? DefaultTabController.of(context);
assert(() {
@@ -1147,7 +1170,7 @@
if (newController == _controller)
return;
- if (_controller != null)
+ if (_controllerIsValid)
_controller.animation.removeListener(_handleTabControllerAnimationTick);
_controller = newController;
if (_controller != null)
@@ -1179,8 +1202,9 @@
@override
void dispose() {
- if (_controller != null)
+ if (_controllerIsValid)
_controller.animation.removeListener(_handleTabControllerAnimationTick);
+ _controller = null;
// We don't own the _controller Animation, so it's not disposed here.
super.dispose();
}
diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart
index 8677c5e..dfbefe3 100644
--- a/packages/flutter/test/material/tabs_test.dart
+++ b/packages/flutter/test/material/tabs_test.dart
@@ -2299,4 +2299,55 @@
final IconThemeData iconTheme = IconTheme.of(tester.element(find.text('A')));
expect(iconTheme.color, equals(selectedTabColor));
});
+
+ testWidgets('Replacing the tabController after disposing the old one', (WidgetTester tester) async {
+ // Regression test for https://github.com/flutter/flutter/issues/32428
+
+ TabController controller = TabController(vsync: const TestVSync(), length: 2);
+ await tester.pumpWidget(
+ MaterialApp(
+ home: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return Scaffold(
+ appBar: AppBar(
+ bottom: TabBar(
+ controller: controller,
+ tabs: List<Widget>.generate(controller.length, (int index) => Tab(text: 'Tab$index')),
+ ),
+ actions: <Widget>[
+ FlatButton(
+ child: const Text('Change TabController length'),
+ onPressed: () {
+ setState(() {
+ controller.dispose();
+ controller = TabController(vsync: const TestVSync(), length: 3);
+ });
+ },
+ ),
+ ],
+ ),
+ body: TabBarView(
+ controller: controller,
+ children: List<Widget>.generate(controller.length, (int index) => Center(child: Text('Tab $index'))),
+ ),
+ );
+ },
+ ),
+ ),
+ );
+
+ expect(controller.index, 0);
+ expect(controller.length, 2);
+ expect(find.text('Tab0'), findsOneWidget);
+ expect(find.text('Tab1'), findsOneWidget);
+ expect(find.text('Tab2'), findsNothing);
+
+ await tester.tap(find.text('Change TabController length'));
+ await tester.pumpAndSettle();
+ expect(controller.index, 0);
+ expect(controller.length, 3);
+ expect(find.text('Tab0'), findsOneWidget);
+ expect(find.text('Tab1'), findsOneWidget);
+ expect(find.text('Tab2'), findsOneWidget);
+ });
}