Skip to content

Commit 02d239a

Browse files
Reland fix memory leaks for tab selector (#147689)
1 parent 145e940 commit 02d239a

File tree

2 files changed

+159
-27
lines changed

2 files changed

+159
-27
lines changed

packages/flutter/lib/src/material/tabs.dart

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2219,7 +2219,7 @@ class TabPageSelectorIndicator extends StatelessWidget {
22192219
///
22202220
/// If a [TabController] is not provided, then there must be a
22212221
/// [DefaultTabController] ancestor.
2222-
class TabPageSelector extends StatelessWidget {
2222+
class TabPageSelector extends StatefulWidget {
22232223
/// Creates a compact widget that indicates which tab has been selected.
22242224
const TabPageSelector({
22252225
super.key,
@@ -2256,6 +2256,67 @@ class TabPageSelector extends StatelessWidget {
22562256
/// Defaults to [BorderStyle.solid] if value is not specified.
22572257
final BorderStyle? borderStyle;
22582258

2259+
@override
2260+
State<TabPageSelector> createState() => _TabPageSelectorState();
2261+
}
2262+
2263+
class _TabPageSelectorState extends State<TabPageSelector> {
2264+
TabController? _previousTabController;
2265+
TabController get _tabController {
2266+
final TabController? tabController = widget.controller ?? DefaultTabController.maybeOf(context);
2267+
assert(() {
2268+
if (tabController == null) {
2269+
throw FlutterError(
2270+
'No TabController for $runtimeType.\n'
2271+
'When creating a $runtimeType, you must either provide an explicit TabController '
2272+
'using the "controller" property, or you must ensure that there is a '
2273+
'DefaultTabController above the $runtimeType.\n'
2274+
'In this case, there was neither an explicit controller nor a default controller.',
2275+
);
2276+
}
2277+
return true;
2278+
}());
2279+
return tabController!;
2280+
}
2281+
2282+
CurvedAnimation? _animation;
2283+
2284+
@override
2285+
void didUpdateWidget (TabPageSelector oldWidget) {
2286+
super.didUpdateWidget(oldWidget);
2287+
if (_previousTabController?.animation != _tabController.animation) {
2288+
_setAnimation();
2289+
}
2290+
if (_previousTabController != _tabController) {
2291+
_previousTabController = _tabController;
2292+
}
2293+
}
2294+
2295+
@override
2296+
void didChangeDependencies() {
2297+
super.didChangeDependencies();
2298+
if (_animation == null || _previousTabController?.animation != _tabController.animation) {
2299+
_setAnimation();
2300+
}
2301+
if (_previousTabController != _tabController) {
2302+
_previousTabController = _tabController;
2303+
}
2304+
}
2305+
2306+
void _setAnimation() {
2307+
_animation?.dispose();
2308+
_animation = CurvedAnimation(
2309+
parent: _tabController.animation!,
2310+
curve: Curves.fastOutSlowIn,
2311+
);
2312+
}
2313+
2314+
@override
2315+
void dispose() {
2316+
_animation?.dispose();
2317+
super.dispose();
2318+
}
2319+
22592320
Widget _buildTabIndicator(
22602321
int tabIndex,
22612322
TabController tabController,
@@ -2290,44 +2351,27 @@ class TabPageSelector extends StatelessWidget {
22902351
return TabPageSelectorIndicator(
22912352
backgroundColor: background,
22922353
borderColor: selectedColorTween.end!,
2293-
size: indicatorSize,
2294-
borderStyle: borderStyle ?? BorderStyle.solid,
2354+
size: widget.indicatorSize,
2355+
borderStyle: widget.borderStyle ?? BorderStyle.solid,
22952356
);
22962357
}
22972358

22982359
@override
22992360
Widget build(BuildContext context) {
2300-
final Color fixColor = color ?? Colors.transparent;
2301-
final Color fixSelectedColor = selectedColor ?? Theme.of(context).colorScheme.secondary;
2361+
final Color fixColor = widget.color ?? Colors.transparent;
2362+
final Color fixSelectedColor = widget.selectedColor ?? Theme.of(context).colorScheme.secondary;
23022363
final ColorTween selectedColorTween = ColorTween(begin: fixColor, end: fixSelectedColor);
23032364
final ColorTween previousColorTween = ColorTween(begin: fixSelectedColor, end: fixColor);
2304-
final TabController? tabController = controller ?? DefaultTabController.maybeOf(context);
23052365
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
2306-
assert(() {
2307-
if (tabController == null) {
2308-
throw FlutterError(
2309-
'No TabController for $runtimeType.\n'
2310-
'When creating a $runtimeType, you must either provide an explicit TabController '
2311-
'using the "controller" property, or you must ensure that there is a '
2312-
'DefaultTabController above the $runtimeType.\n'
2313-
'In this case, there was neither an explicit controller nor a default controller.',
2314-
);
2315-
}
2316-
return true;
2317-
}());
2318-
final Animation<double> animation = CurvedAnimation(
2319-
parent: tabController!.animation!,
2320-
curve: Curves.fastOutSlowIn,
2321-
);
23222366
return AnimatedBuilder(
2323-
animation: animation,
2367+
animation: _animation!,
23242368
builder: (BuildContext context, Widget? child) {
23252369
return Semantics(
2326-
label: localizations.tabLabel(tabIndex: tabController.index + 1, tabCount: tabController.length),
2370+
label: localizations.tabLabel(tabIndex: _tabController.index + 1, tabCount: _tabController.length),
23272371
child: Row(
23282372
mainAxisSize: MainAxisSize.min,
2329-
children: List<Widget>.generate(tabController.length, (int tabIndex) {
2330-
return _buildTabIndicator(tabIndex, tabController, selectedColorTween, previousColorTween);
2373+
children: List<Widget>.generate(_tabController.length, (int tabIndex) {
2374+
return _buildTabIndicator(tabIndex, _tabController, selectedColorTween, previousColorTween);
23312375
}).toList(),
23322376
),
23332377
);

packages/flutter/test/material/page_selector_test.dart

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'package:flutter/material.dart';
66
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
78

89
const Color kSelectedColor = Color(0xFF00FF00);
910
const Color kUnselectedColor = Colors.transparent;
@@ -86,7 +87,10 @@ void main() {
8687
expect(indicatorColors(tester), const <Color>[kUnselectedColor, kUnselectedColor, kSelectedColor]);
8788
});
8889

89-
testWidgets('PageSelector responds correctly to TabController.animateTo()', (WidgetTester tester) async {
90+
testWidgets('PageSelector responds correctly to TabController.animateTo()',
91+
// TODO(polina-c): remove when fixed https://github.com/flutter/flutter/issues/145600 [leak-tracking-opt-in]
92+
experimentalLeakTesting: LeakTesting.settings.withTracked(classes: const <String>['CurvedAnimation']),
93+
(WidgetTester tester) async {
9094
final TabController tabController = TabController(
9195
vsync: const TestVSync(),
9296
length: 3,
@@ -277,4 +281,88 @@ void main() {
277281
expect(indicator.borderStyle, BorderStyle.solid);
278282
}
279283
});
284+
285+
testWidgets('PageSelector responds correctly to TabController.animateTo() from the default tab controller',
286+
// TODO(polina-c): remove when fixed https://github.com/flutter/flutter/issues/145600 [leak-tracking-opt-in]
287+
experimentalLeakTesting: LeakTesting.settings.withTracked(classes: const <String>['CurvedAnimation']),
288+
(WidgetTester tester) async {
289+
await tester.pumpWidget(
290+
Localizations(
291+
locale: const Locale('en', 'US'),
292+
delegates: const <LocalizationsDelegate<dynamic>>[
293+
DefaultMaterialLocalizations.delegate,
294+
DefaultWidgetsLocalizations.delegate,
295+
],
296+
child: Directionality(
297+
textDirection: TextDirection.ltr,
298+
child: Theme(
299+
data: ThemeData(colorScheme: const ColorScheme.light().copyWith(secondary: kSelectedColor)),
300+
child: const SizedBox.expand(
301+
child: Center(
302+
child: SizedBox(
303+
width: 400.0,
304+
height: 400.0,
305+
child: DefaultTabController(
306+
length: 3,
307+
child: Column(
308+
children: <Widget>[
309+
TabPageSelector(
310+
),
311+
Flexible(
312+
child: TabBarView(
313+
children: <Widget>[
314+
Center(child: Text('0')),
315+
Center(child: Text('1')),
316+
Center(child: Text('2')),
317+
],
318+
),
319+
),
320+
],
321+
),
322+
),
323+
),
324+
),
325+
),
326+
),
327+
),
328+
),
329+
);
330+
331+
final TabController tabController = DefaultTabController.of(tester.element(find.byType(TabPageSelector)));
332+
333+
expect(tabController.index, 0);
334+
expect(indicatorColors(tester), const <Color>[kSelectedColor, kUnselectedColor, kUnselectedColor]);
335+
336+
tabController.animateTo(1, duration: const Duration(milliseconds: 200));
337+
await tester.pump();
338+
// Verify that indicator 0's color is becoming increasingly transparent,
339+
// and indicator 1's color is becoming increasingly opaque during the
340+
// 200ms animation. Indicator 2 remains transparent throughout.
341+
await tester.pump(const Duration(milliseconds: 10));
342+
List<Color> colors = indicatorColors(tester);
343+
expect(colors[0].alpha, greaterThan(colors[1].alpha));
344+
expect(colors[2], kUnselectedColor);
345+
await tester.pump(const Duration(milliseconds: 175));
346+
colors = indicatorColors(tester);
347+
expect(colors[0].alpha, lessThan(colors[1].alpha));
348+
expect(colors[2], kUnselectedColor);
349+
await tester.pumpAndSettle();
350+
expect(tabController.index, 1);
351+
expect(indicatorColors(tester), const <Color>[kUnselectedColor, kSelectedColor, kUnselectedColor]);
352+
353+
tabController.animateTo(2, duration: const Duration(milliseconds: 200));
354+
await tester.pump();
355+
// Same animation test as above for indicators 1 and 2.
356+
await tester.pump(const Duration(milliseconds: 10));
357+
colors = indicatorColors(tester);
358+
expect(colors[1].alpha, greaterThan(colors[2].alpha));
359+
expect(colors[0], kUnselectedColor);
360+
await tester.pump(const Duration(milliseconds: 175));
361+
colors = indicatorColors(tester);
362+
expect(colors[1].alpha, lessThan(colors[2].alpha));
363+
expect(colors[0], kUnselectedColor);
364+
await tester.pumpAndSettle();
365+
expect(tabController.index, 2);
366+
expect(indicatorColors(tester), const <Color>[kUnselectedColor, kUnselectedColor, kSelectedColor]);
367+
});
280368
}

0 commit comments

Comments
 (0)