diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index 6cb60b377914..96e3ad702185 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -2219,7 +2219,7 @@ class TabPageSelectorIndicator extends StatelessWidget { /// /// 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 @@ class TabPageSelector extends StatelessWidget { /// Defaults to [BorderStyle.solid] if value is not specified. final BorderStyle? borderStyle; + @override + State createState() => _TabPageSelectorState(); +} + +class _TabPageSelectorState extends State { + 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 @@ class TabPageSelector extends StatelessWidget { 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 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.generate(tabController.length, (int tabIndex) { - return _buildTabIndicator(tabIndex, tabController, selectedColorTween, previousColorTween); + children: List.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 8e988eda4437..a7640c7c0dea 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 @@ void main() { expect(indicatorColors(tester), const [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 ['CurvedAnimation']), + (WidgetTester tester) async { final TabController tabController = TabController( vsync: const TestVSync(), length: 3, @@ -277,4 +281,88 @@ void main() { 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 ['CurvedAnimation']), + (WidgetTester tester) async { + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const >[ + 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: [ + TabPageSelector( + ), + Flexible( + child: TabBarView( + children: [ + 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 [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 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 [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 [kUnselectedColor, kUnselectedColor, kSelectedColor]); + }); }