Reland fix memory leaks for tab selector (#147689)
diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart
index 6cb60b3..96e3ad7 100644
--- a/packages/flutter/lib/src/material/tabs.dart
+++ b/packages/flutter/lib/src/material/tabs.dart
@@ -2219,7 +2219,7 @@
///
/// If a [TabController] is not provided, then there must be a
/// [DefaultTabController] ancestor.
-class TabPageSelector extends StatelessWidget {
+class TabPageSelector extends StatefulWidget {
/// Creates a compact widget that indicates which tab has been selected.
const TabPageSelector({
super.key,
@@ -2256,6 +2256,67 @@
/// Defaults to [BorderStyle.solid] if value is not specified.
final BorderStyle? borderStyle;
+ @override
+ State<TabPageSelector> createState() => _TabPageSelectorState();
+}
+
+class _TabPageSelectorState extends State<TabPageSelector> {
+ TabController? _previousTabController;
+ TabController get _tabController {
+ final TabController? tabController = widget.controller ?? DefaultTabController.maybeOf(context);
+ assert(() {
+ if (tabController == null) {
+ throw FlutterError(
+ 'No TabController for $runtimeType.\n'
+ 'When creating a $runtimeType, you must either provide an explicit TabController '
+ 'using the "controller" property, or you must ensure that there is a '
+ 'DefaultTabController above the $runtimeType.\n'
+ 'In this case, there was neither an explicit controller nor a default controller.',
+ );
+ }
+ return true;
+ }());
+ return tabController!;
+ }
+
+ CurvedAnimation? _animation;
+
+ @override
+ void didUpdateWidget (TabPageSelector oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (_previousTabController?.animation != _tabController.animation) {
+ _setAnimation();
+ }
+ if (_previousTabController != _tabController) {
+ _previousTabController = _tabController;
+ }
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (_animation == null || _previousTabController?.animation != _tabController.animation) {
+ _setAnimation();
+ }
+ if (_previousTabController != _tabController) {
+ _previousTabController = _tabController;
+ }
+ }
+
+ void _setAnimation() {
+ _animation?.dispose();
+ _animation = CurvedAnimation(
+ parent: _tabController.animation!,
+ curve: Curves.fastOutSlowIn,
+ );
+ }
+
+ @override
+ void dispose() {
+ _animation?.dispose();
+ super.dispose();
+ }
+
Widget _buildTabIndicator(
int tabIndex,
TabController tabController,
@@ -2290,44 +2351,27 @@
return TabPageSelectorIndicator(
backgroundColor: background,
borderColor: selectedColorTween.end!,
- size: indicatorSize,
- borderStyle: borderStyle ?? BorderStyle.solid,
+ size: widget.indicatorSize,
+ borderStyle: widget.borderStyle ?? BorderStyle.solid,
);
}
@override
Widget build(BuildContext context) {
- final Color fixColor = color ?? Colors.transparent;
- final Color fixSelectedColor = selectedColor ?? Theme.of(context).colorScheme.secondary;
+ final Color fixColor = widget.color ?? Colors.transparent;
+ final Color fixSelectedColor = widget.selectedColor ?? Theme.of(context).colorScheme.secondary;
final ColorTween selectedColorTween = ColorTween(begin: fixColor, end: fixSelectedColor);
final ColorTween previousColorTween = ColorTween(begin: fixSelectedColor, end: fixColor);
- final TabController? tabController = controller ?? DefaultTabController.maybeOf(context);
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
- assert(() {
- if (tabController == null) {
- throw FlutterError(
- 'No TabController for $runtimeType.\n'
- 'When creating a $runtimeType, you must either provide an explicit TabController '
- 'using the "controller" property, or you must ensure that there is a '
- 'DefaultTabController above the $runtimeType.\n'
- 'In this case, there was neither an explicit controller nor a default controller.',
- );
- }
- return true;
- }());
- final Animation<double> animation = CurvedAnimation(
- parent: tabController!.animation!,
- curve: Curves.fastOutSlowIn,
- );
return AnimatedBuilder(
- animation: animation,
+ animation: _animation!,
builder: (BuildContext context, Widget? child) {
return Semantics(
- label: localizations.tabLabel(tabIndex: tabController.index + 1, tabCount: tabController.length),
+ label: localizations.tabLabel(tabIndex: _tabController.index + 1, tabCount: _tabController.length),
child: Row(
mainAxisSize: MainAxisSize.min,
- children: List<Widget>.generate(tabController.length, (int tabIndex) {
- return _buildTabIndicator(tabIndex, tabController, selectedColorTween, previousColorTween);
+ children: List<Widget>.generate(_tabController.length, (int tabIndex) {
+ return _buildTabIndicator(tabIndex, _tabController, selectedColorTween, previousColorTween);
}).toList(),
),
);
diff --git a/packages/flutter/test/material/page_selector_test.dart b/packages/flutter/test/material/page_selector_test.dart
index 8e988ed..a7640c7 100644
--- a/packages/flutter/test/material/page_selector_test.dart
+++ b/packages/flutter/test/material/page_selector_test.dart
@@ -4,6 +4,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
const Color kSelectedColor = Color(0xFF00FF00);
const Color kUnselectedColor = Colors.transparent;
@@ -86,7 +87,10 @@
expect(indicatorColors(tester), const <Color>[kUnselectedColor, kUnselectedColor, kSelectedColor]);
});
- testWidgets('PageSelector responds correctly to TabController.animateTo()', (WidgetTester tester) async {
+ testWidgets('PageSelector responds correctly to TabController.animateTo()',
+ // TODO(polina-c): remove when fixed https://github.com/flutter/flutter/issues/145600 [leak-tracking-opt-in]
+ experimentalLeakTesting: LeakTesting.settings.withTracked(classes: const <String>['CurvedAnimation']),
+ (WidgetTester tester) async {
final TabController tabController = TabController(
vsync: const TestVSync(),
length: 3,
@@ -277,4 +281,88 @@
expect(indicator.borderStyle, BorderStyle.solid);
}
});
+
+ testWidgets('PageSelector responds correctly to TabController.animateTo() from the default tab controller',
+ // TODO(polina-c): remove when fixed https://github.com/flutter/flutter/issues/145600 [leak-tracking-opt-in]
+ experimentalLeakTesting: LeakTesting.settings.withTracked(classes: const <String>['CurvedAnimation']),
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ Localizations(
+ locale: const Locale('en', 'US'),
+ delegates: const <LocalizationsDelegate<dynamic>>[
+ DefaultMaterialLocalizations.delegate,
+ DefaultWidgetsLocalizations.delegate,
+ ],
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: Theme(
+ data: ThemeData(colorScheme: const ColorScheme.light().copyWith(secondary: kSelectedColor)),
+ child: const SizedBox.expand(
+ child: Center(
+ child: SizedBox(
+ width: 400.0,
+ height: 400.0,
+ child: DefaultTabController(
+ length: 3,
+ child: Column(
+ children: <Widget>[
+ TabPageSelector(
+ ),
+ Flexible(
+ child: TabBarView(
+ children: <Widget>[
+ Center(child: Text('0')),
+ Center(child: Text('1')),
+ Center(child: Text('2')),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final TabController tabController = DefaultTabController.of(tester.element(find.byType(TabPageSelector)));
+
+ expect(tabController.index, 0);
+ expect(indicatorColors(tester), const <Color>[kSelectedColor, kUnselectedColor, kUnselectedColor]);
+
+ tabController.animateTo(1, duration: const Duration(milliseconds: 200));
+ await tester.pump();
+ // Verify that indicator 0's color is becoming increasingly transparent,
+ // and indicator 1's color is becoming increasingly opaque during the
+ // 200ms animation. Indicator 2 remains transparent throughout.
+ await tester.pump(const Duration(milliseconds: 10));
+ List<Color> colors = indicatorColors(tester);
+ expect(colors[0].alpha, greaterThan(colors[1].alpha));
+ expect(colors[2], kUnselectedColor);
+ await tester.pump(const Duration(milliseconds: 175));
+ colors = indicatorColors(tester);
+ expect(colors[0].alpha, lessThan(colors[1].alpha));
+ expect(colors[2], kUnselectedColor);
+ await tester.pumpAndSettle();
+ expect(tabController.index, 1);
+ expect(indicatorColors(tester), const <Color>[kUnselectedColor, kSelectedColor, kUnselectedColor]);
+
+ tabController.animateTo(2, duration: const Duration(milliseconds: 200));
+ await tester.pump();
+ // Same animation test as above for indicators 1 and 2.
+ await tester.pump(const Duration(milliseconds: 10));
+ colors = indicatorColors(tester);
+ expect(colors[1].alpha, greaterThan(colors[2].alpha));
+ expect(colors[0], kUnselectedColor);
+ await tester.pump(const Duration(milliseconds: 175));
+ colors = indicatorColors(tester);
+ expect(colors[1].alpha, lessThan(colors[2].alpha));
+ expect(colors[0], kUnselectedColor);
+ await tester.pumpAndSettle();
+ expect(tabController.index, 2);
+ expect(indicatorColors(tester), const <Color>[kUnselectedColor, kUnselectedColor, kSelectedColor]);
+ });
}