diff --git a/packages/flutter/lib/src/material/tab_controller.dart b/packages/flutter/lib/src/material/tab_controller.dart index fd20e091a5..df84c196df 100644 --- a/packages/flutter/lib/src/material/tab_controller.dart +++ b/packages/flutter/lib/src/material/tab_controller.dart @@ -101,11 +101,12 @@ class TabController extends ChangeNotifier { /// /// The `initialIndex` must be valid given [length] and must not be null. If /// [length] is zero, then `initialIndex` must be 0 (the default). - TabController({ int initialIndex = 0, required this.length, required TickerProvider vsync }) + TabController({ int initialIndex = 0, Duration? animationDuration, required this.length, required TickerProvider vsync}) : assert(length != null && length >= 0), assert(initialIndex != null && initialIndex >= 0 && (length == 0 || initialIndex < length)), _index = initialIndex, _previousIndex = initialIndex, + _animationDuration = animationDuration ?? kTabScrollDuration, _animationController = AnimationController.unbounded( value: initialIndex.toDouble(), vsync: vsync, @@ -117,14 +118,16 @@ class TabController extends ChangeNotifier { required int index, required int previousIndex, required AnimationController? animationController, + required Duration animationDuration, required this.length, }) : _index = index, _previousIndex = previousIndex, - _animationController = animationController; + _animationController = animationController, + _animationDuration = animationDuration; - /// Creates a new [TabController] with `index`, `previousIndex`, and `length` - /// if they are non-null. + /// Creates a new [TabController] with `index`, `previousIndex`, `length`, and + /// `animationDuration` if they are non-null. /// /// This method is used by [DefaultTabController]. /// @@ -134,6 +137,7 @@ class TabController extends ChangeNotifier { required int? index, required int? length, required int? previousIndex, + required Duration? animationDuration, }) { if (index != null) { _animationController!.value = index.toDouble(); @@ -143,6 +147,7 @@ class TabController extends ChangeNotifier { length: length ?? this.length, animationController: _animationController, previousIndex: previousIndex ?? _previousIndex, + animationDuration: animationDuration ?? _animationDuration, ); } @@ -159,6 +164,12 @@ class TabController extends ChangeNotifier { Animation? get animation => _animationController?.view; AnimationController? _animationController; + /// Controls the duration of TabController and TabBarView animations. + /// + /// Defaults to kTabScrollDuration. + Duration get animationDuration => _animationDuration; + final Duration _animationDuration; + /// The total number of tabs. /// /// Typically greater than one. Must match [TabBar.tabs]'s and @@ -174,7 +185,7 @@ class TabController extends ChangeNotifier { return; _previousIndex = index; _index = value; - if (duration != null) { + if (duration != null && duration > Duration.zero) { _indexIsChangingCount += 1; notifyListeners(); // Because the value of indexIsChanging may have changed. _animationController! @@ -228,8 +239,8 @@ class TabController extends ChangeNotifier { /// /// While the animation is running [indexIsChanging] is true. When the /// animation completes [offset] will be 0.0. - void animateTo(int value, { Duration duration = kTabScrollDuration, Curve curve = Curves.ease }) { - _changeIndex(value, duration: duration, curve: curve); + void animateTo(int value, { Duration? duration, Curve curve = Curves.ease }) { + _changeIndex(value, duration: duration ?? _animationDuration, curve: curve); } /// The difference between the [animation]'s value and [index]. @@ -333,6 +344,7 @@ class DefaultTabController extends StatefulWidget { required this.length, this.initialIndex = 0, required this.child, + this.animationDuration, }) : assert(initialIndex != null), assert(length >= 0), assert(length == 0 || (initialIndex >= 0 && initialIndex < length)), @@ -349,6 +361,11 @@ class DefaultTabController extends StatefulWidget { /// Defaults to zero. final int initialIndex; + /// Controls the duration of DefaultTabController and TabBarView animations. + /// + /// Defaults to kTabScrollDuration. + final Duration? animationDuration; + /// The widget below this widget in the tree. /// /// Typically a [Scaffold] whose [AppBar] includes a [TabBar]. @@ -384,6 +401,7 @@ class _DefaultTabControllerState extends State with Single vsync: this, length: widget.length, initialIndex: widget.initialIndex, + animationDuration: widget.animationDuration, ); } @@ -416,9 +434,19 @@ class _DefaultTabControllerState extends State with Single } _controller = _controller._copyWith( length: widget.length, + animationDuration: widget.animationDuration, index: newIndex, previousIndex: previousIndex, ); } + + if (oldWidget.animationDuration != widget.animationDuration) { + _controller = _controller._copyWith( + length: widget.length, + animationDuration: widget.animationDuration, + index: _controller.index, + previousIndex: _controller.previousIndex, + ); + } } } diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index 34023dfd53..ea0aac1951 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -1392,10 +1392,18 @@ class _TabBarViewState extends State { if (_pageController.page == _currentIndex!.toDouble()) return Future.value(); + final Duration duration = _controller!.animationDuration; + + if (duration == Duration.zero) { + _pageController.jumpToPage(_currentIndex!); + return Future.value(); + } + final int previousIndex = _controller!.previousIndex; + if ((_currentIndex! - previousIndex).abs() == 1) { _warpUnderwayCount += 1; - await _pageController.animateToPage(_currentIndex!, duration: kTabScrollDuration, curve: Curves.ease); + await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease); _warpUnderwayCount -= 1; return Future.value(); } @@ -1415,7 +1423,7 @@ class _TabBarViewState extends State { }); _pageController.jumpToPage(initialPage); - await _pageController.animateToPage(_currentIndex!, duration: kTabScrollDuration, curve: Curves.ease); + await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease); if (!mounted) return Future.value(); setState(() { diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index be68626dde..7e0adf8da9 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -109,9 +109,11 @@ Widget buildFrame({ required String value, bool isScrollable = false, Color? indicatorColor, + Duration? animationDuration, }) { return boilerplate( child: DefaultTabController( + animationDuration: animationDuration, initialIndex: tabs.indexOf(value), length: tabs.length, child: TabBar( @@ -937,6 +939,262 @@ void main() { expect(find.text('Second'), findsNothing); }); + testWidgets('TabBar animationDuration sets indicator animation duration', (WidgetTester tester) async { + const Duration animationDuration = Duration(milliseconds: 100); + final List tabs = ['A', 'B', 'C']; + + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', animationDuration: animationDuration)); + final TabController controller = DefaultTabController.of(tester.element(find.text('A')))!; + + await tester.tap(find.text('A')); + await tester.pump(); + expect(controller.indexIsChanging, true); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(animationDuration); + expect(controller.index, 0); + expect(controller.previousIndex, 1); + expect(controller.indexIsChanging, false); + + //Test when index diff is greater than 1 + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', animationDuration: animationDuration)); + await tester.tap(find.text('C')); + await tester.pump(); + expect(controller.indexIsChanging, true); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(animationDuration); + expect(controller.index, 2); + expect(controller.previousIndex, 0); + expect(controller.indexIsChanging, false); + }); + + testWidgets('TabBarView controller sets animation duration', (WidgetTester tester) async { + const Duration animationDuration = Duration(milliseconds: 100); + final List tabs = ['A', 'B', 'C']; + + final TabController tabController = TabController( + vsync: const TestVSync(), + initialIndex: 1, + length: tabs.length, + animationDuration: animationDuration, + ); + await tester.pumpWidget(boilerplate( + child: Column( + children: [ + TabBar( + tabs: tabs.map((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox( + width: 400.0, + height: 400.0, + child: TabBarView( + controller: tabController, + children: const [ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + )); + + expect(tabController.index, 1); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + expect(position.pixels, 400); + await tester.tap(find.text('C')); + await tester.pump(); + expect(position.pixels, 400); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(animationDuration); + expect(position.pixels, 800); + }); + + testWidgets('TabBar tap skips indicator animation when disabled in controller', (WidgetTester tester) async { + final List tabs = ['A', 'B']; + + const Color indicatorColor = Color(0xFFFF0000); + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'A', indicatorColor: indicatorColor, animationDuration: Duration.zero)); + + final RenderBox box = tester.renderObject(find.byType(TabBar)); + final TabIndicatorRecordingCanvas canvas = TabIndicatorRecordingCanvas(indicatorColor); + final TestRecordingPaintingContext context = TestRecordingPaintingContext(canvas); + + box.paint(context, Offset.zero); + final Rect indicatorRect0 = canvas.indicatorRect; + expect(indicatorRect0.left, 0.0); + expect(indicatorRect0.width, 400.0); + expect(indicatorRect0.height, 2.0); + + await tester.tap(find.text('B')); + await tester.pump(); + box.paint(context, Offset.zero); + final Rect indicatorRect2 = canvas.indicatorRect; + expect(indicatorRect2.left, 400.0); + expect(indicatorRect2.width, 400.0); + expect(indicatorRect2.height, 2.0); + }); + + testWidgets('TabBar tap changes index instantly when animation is disabled in controller', (WidgetTester tester) async { + final List tabs = ['A', 'B', 'C']; + + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', animationDuration: Duration.zero)); + final TabController controller = DefaultTabController.of(tester.element(find.text('A')))!; + + await tester.tap(find.text('A')); + await tester.pump(); + expect(controller.index, 0); + expect(controller.previousIndex, 1); + expect(controller.indexIsChanging, false); + + //Test when index diff is greater than 1 + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', animationDuration: Duration.zero)); + await tester.tap(find.text('C')); + await tester.pump(); + expect(controller.index, 2); + expect(controller.previousIndex, 0); + expect(controller.indexIsChanging, false); + }); + + testWidgets('TabBarView skips animation when disabled in controller', (WidgetTester tester) async { + final List tabs = ['A', 'B', 'C']; + final TabController tabController = TabController( + vsync: const TestVSync(), + initialIndex: 1, + length: tabs.length, + animationDuration: Duration.zero, + ); + await tester.pumpWidget(boilerplate( + child: Column( + children: [ + TabBar( + tabs: tabs.map((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox( + width: 400.0, + height: 400.0, + child: TabBarView( + controller: tabController, + children: const [ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + )); + + expect(tabController.index, 1); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + expect(position.pixels, 400); + await tester.tap(find.text('C')); + await tester.pump(); + expect(position.pixels, 800); + }); + + testWidgets('TabBarView skips animation when disabled in controller - skip tabs', (WidgetTester tester) async { + final List tabs = ['A', 'B', 'C']; + final TabController tabController = TabController( + vsync: const TestVSync(), + length: tabs.length, + animationDuration: Duration.zero, + ); + await tester.pumpWidget(boilerplate( + child: Column( + children: [ + TabBar( + tabs: tabs.map((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox( + width: 400.0, + height: 400.0, + child: TabBarView( + controller: tabController, + children: const [ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + )); + + expect(tabController.index, 0); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + expect(position.pixels, 0); + await tester.tap(find.text('C')); + await tester.pump(); + expect(position.pixels, 800); + }); + + testWidgets('TabBarView skips animation when disabled in controller - two tabs', (WidgetTester tester) async { + final List tabs = ['A', 'B']; + final TabController tabController = TabController( + vsync: const TestVSync(), + length: tabs.length, + animationDuration: Duration.zero, + ); + await tester.pumpWidget(boilerplate( + child: Column( + children: [ + TabBar( + tabs: tabs.map((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox( + width: 400.0, + height: 400.0, + child: TabBarView( + controller: tabController, + children: const [ + Center(child: Text('0')), + Center(child: Text('1')), + ], + ), + ), + ], + ), + )); + + expect(tabController.index, 0); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + expect(position.pixels, 0); + await tester.tap(find.text('B')); + await tester.pump(); + expect(position.pixels, 400); + }); + testWidgets('TabBar tap animates the selection indicator', (WidgetTester tester) async { // This is a regression test for https://github.com/flutter/flutter/issues/7479