diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index 6154412637a..3c24ab10626 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -1529,10 +1529,10 @@ class TabBarView extends StatefulWidget { class _TabBarViewState extends State { TabController? _controller; late PageController _pageController; - late List _children; late List _childrenWithKey; int? _currentIndex; int _warpUnderwayCount = 0; + int _scrollUnderwayCount = 0; bool _debugHasScheduledValidChildrenCountCheck = false; // If the TabBarView is rebuilt with a new tab controller, the caller should @@ -1568,6 +1568,22 @@ class _TabBarViewState extends State { } } + void _jumpToPage(int page) { + _warpUnderwayCount += 1; + _pageController.jumpToPage(page); + _warpUnderwayCount -= 1; + } + + Future _animateToPage( + int page, { + required Duration duration, + required Curve curve, + }) async { + _warpUnderwayCount += 1; + await _pageController.animateToPage(page, duration: duration, curve: curve); + _warpUnderwayCount -= 1; + } + @override void initState() { super.initState(); @@ -1591,10 +1607,10 @@ class _TabBarViewState extends State { if (widget.controller != oldWidget.controller) { _updateTabController(); _currentIndex = _controller!.index; - _warpUnderwayCount += 1; - _pageController.jumpToPage(_currentIndex!); - _warpUnderwayCount -= 1; + _jumpToPage(_currentIndex!); } + // While a warp is under way, we stop updating the tab page contents. + // This is tracked in https://github.com/flutter/flutter/issues/31269. if (widget.children != oldWidget.children && _warpUnderwayCount == 0) { _updateChildren(); } @@ -1611,12 +1627,11 @@ class _TabBarViewState extends State { } void _updateChildren() { - _children = widget.children; _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children); } void _handleTabControllerAnimationTick() { - if (_warpUnderwayCount > 0 || !_controller!.indexIsChanging) { + if (_scrollUnderwayCount > 0 || !_controller!.indexIsChanging) { return; } // This widget is driving the controller's animation. @@ -1626,71 +1641,73 @@ class _TabBarViewState extends State { } } - Future _warpToCurrentIndex() async { - if (!mounted) { - return Future.value(); + void _warpToCurrentIndex() { + if (!mounted || _pageController.page == _currentIndex!.toDouble()) { + return; } - if (_pageController.page == _currentIndex!.toDouble()) { - return Future.value(); + final bool adjacentDestination = (_currentIndex! - _controller!.previousIndex).abs() == 1; + if (adjacentDestination) { + _warpToAdjacentTab(_controller!.animationDuration); + } else { + _warpToNonAdjacentTab(_controller!.animationDuration); } + } - final Duration duration = _controller!.animationDuration; + Future _warpToAdjacentTab(Duration duration) async { + if (duration == Duration.zero) { + _jumpToPage(_currentIndex!); + } else { + await _animateToPage(_currentIndex!, duration: duration, curve: Curves.ease); + } + if (mounted) { + setState(() { _updateChildren(); }); + } + return Future.value(); + } + + Future _warpToNonAdjacentTab(Duration duration) async { final int previousIndex = _controller!.previousIndex; - - if ((_currentIndex! - previousIndex).abs() == 1) { - if (duration == Duration.zero) { - _pageController.jumpToPage(_currentIndex!); - return Future.value(); - } - _warpUnderwayCount += 1; - await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease); - _warpUnderwayCount -= 1; - - if (mounted && widget.children != _children) { - setState(() { _updateChildren(); }); - } - return Future.value(); - } - assert((_currentIndex! - previousIndex).abs() > 1); + + // initialPage defines which page is shown when starting the animation. + // This page is adjacent to the destination page. final int initialPage = _currentIndex! > previousIndex ? _currentIndex! - 1 : _currentIndex! + 1; - final List originalChildren = _childrenWithKey; - setState(() { - _warpUnderwayCount += 1; + setState(() { + // Needed for `RenderSliverMultiBoxAdaptor.move` and kept alive children. + // For motivation, see https://github.com/flutter/flutter/pull/29188 and + // https://github.com/flutter/flutter/issues/27010#issuecomment-486475152. _childrenWithKey = List.of(_childrenWithKey, growable: false); final Widget temp = _childrenWithKey[initialPage]; _childrenWithKey[initialPage] = _childrenWithKey[previousIndex]; _childrenWithKey[previousIndex] = temp; }); - _pageController.jumpToPage(initialPage); + // Make a first jump to the adjacent page. + _jumpToPage(initialPage); + + // Jump or animate to the destination page. if (duration == Duration.zero) { - _pageController.jumpToPage(_currentIndex!); + _jumpToPage(_currentIndex!); } else { - await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease); - - if (!mounted) { - return Future.value(); - } + await _animateToPage(_currentIndex!, duration: duration, curve: Curves.ease); } - setState(() { - _warpUnderwayCount -= 1; - if (widget.children != _children) { - _updateChildren(); - } else { - _childrenWithKey = originalChildren; - } - }); + if (mounted) { + setState(() { _updateChildren(); }); + } + } + + void _syncControllerOffset() { + _controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0); } // Called when the PageView scrolls bool _handleScrollNotification(ScrollNotification notification) { - if (_warpUnderwayCount > 0) { + if (_warpUnderwayCount > 0 || _scrollUnderwayCount > 0) { return false; } @@ -1698,21 +1715,22 @@ class _TabBarViewState extends State { return false; } - _warpUnderwayCount += 1; + _scrollUnderwayCount += 1; if (notification is ScrollUpdateNotification && !_controller!.indexIsChanging) { - if ((_pageController.page! - _controller!.index).abs() > 1.0) { + final bool pageChanged = (_pageController.page! - _controller!.index).abs() > 1.0; + if (pageChanged) { _controller!.index = _pageController.page!.round(); _currentIndex =_controller!.index; } - _controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0); + _syncControllerOffset(); } else if (notification is ScrollEndNotification) { _controller!.index = _pageController.page!.round(); _currentIndex = _controller!.index; if (!_controller!.indexIsChanging) { - _controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0); + _syncControllerOffset(); } } - _warpUnderwayCount -= 1; + _scrollUnderwayCount -= 1; return false; } diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index c03d253fd1c..2c8e58187b2 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -1120,6 +1120,65 @@ void main() { expect(position.pixels, 800); }); + testWidgets('TabBarView animation can be interrupted', (WidgetTester tester) async { + const Duration animationDuration = Duration(seconds: 2); + final List tabs = ['A', 'B', 'C']; + + final TabController tabController = TabController( + vsync: const TestVSync(), + 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, 0); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller; + final ScrollPosition position = pageController.position; + + expect(position.pixels, 0.0); + + await tester.tap(find.text('C')); + await tester.pump(const Duration(milliseconds: 10)); // TODO(bleroux): find why this is needed. + + // Runs the animation for half of the animation duration. + await tester.pump(const Duration(seconds: 1)); + + // The position should be between page 1 and page 2. + expect(position.pixels, greaterThan(400.0)); + expect(position.pixels, lessThan(800.0)); + + // Switch to another tab before the end of the animation. + await tester.tap(find.text('A')); + await tester.pump(const Duration(milliseconds: 10)); // TODO(bleroux): find why this is needed. + await tester.pump(animationDuration); + expect(position.pixels, 0.0); + + await tester.pumpAndSettle(); // Finish the animation. + }); + testWidgets('TabBarView viewportFraction sets PageView viewport fraction', (WidgetTester tester) async { const Duration animationDuration = Duration(milliseconds: 100); final List tabs = ['A', 'B', 'C'];