Skip to content

Commit 5d10cc2

Browse files
authored
Fix TabBarView and TabBar animations are not synchronized (#122021)
1 parent 27aa83b commit 5d10cc2

File tree

2 files changed

+127
-50
lines changed

2 files changed

+127
-50
lines changed

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

Lines changed: 68 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,10 +1529,10 @@ class TabBarView extends StatefulWidget {
15291529
class _TabBarViewState extends State<TabBarView> {
15301530
TabController? _controller;
15311531
late PageController _pageController;
1532-
late List<Widget> _children;
15331532
late List<Widget> _childrenWithKey;
15341533
int? _currentIndex;
15351534
int _warpUnderwayCount = 0;
1535+
int _scrollUnderwayCount = 0;
15361536
bool _debugHasScheduledValidChildrenCountCheck = false;
15371537

15381538
// If the TabBarView is rebuilt with a new tab controller, the caller should
@@ -1568,6 +1568,22 @@ class _TabBarViewState extends State<TabBarView> {
15681568
}
15691569
}
15701570

1571+
void _jumpToPage(int page) {
1572+
_warpUnderwayCount += 1;
1573+
_pageController.jumpToPage(page);
1574+
_warpUnderwayCount -= 1;
1575+
}
1576+
1577+
Future<void> _animateToPage(
1578+
int page, {
1579+
required Duration duration,
1580+
required Curve curve,
1581+
}) async {
1582+
_warpUnderwayCount += 1;
1583+
await _pageController.animateToPage(page, duration: duration, curve: curve);
1584+
_warpUnderwayCount -= 1;
1585+
}
1586+
15711587
@override
15721588
void initState() {
15731589
super.initState();
@@ -1591,10 +1607,10 @@ class _TabBarViewState extends State<TabBarView> {
15911607
if (widget.controller != oldWidget.controller) {
15921608
_updateTabController();
15931609
_currentIndex = _controller!.index;
1594-
_warpUnderwayCount += 1;
1595-
_pageController.jumpToPage(_currentIndex!);
1596-
_warpUnderwayCount -= 1;
1610+
_jumpToPage(_currentIndex!);
15971611
}
1612+
// While a warp is under way, we stop updating the tab page contents.
1613+
// This is tracked in https://github.com/flutter/flutter/issues/31269.
15981614
if (widget.children != oldWidget.children && _warpUnderwayCount == 0) {
15991615
_updateChildren();
16001616
}
@@ -1611,12 +1627,11 @@ class _TabBarViewState extends State<TabBarView> {
16111627
}
16121628

16131629
void _updateChildren() {
1614-
_children = widget.children;
16151630
_childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children);
16161631
}
16171632

16181633
void _handleTabControllerAnimationTick() {
1619-
if (_warpUnderwayCount > 0 || !_controller!.indexIsChanging) {
1634+
if (_scrollUnderwayCount > 0 || !_controller!.indexIsChanging) {
16201635
return;
16211636
} // This widget is driving the controller's animation.
16221637

@@ -1626,93 +1641,96 @@ class _TabBarViewState extends State<TabBarView> {
16261641
}
16271642
}
16281643

1629-
Future<void> _warpToCurrentIndex() async {
1630-
if (!mounted) {
1631-
return Future<void>.value();
1644+
void _warpToCurrentIndex() {
1645+
if (!mounted || _pageController.page == _currentIndex!.toDouble()) {
1646+
return;
16321647
}
16331648

1634-
if (_pageController.page == _currentIndex!.toDouble()) {
1635-
return Future<void>.value();
1649+
final bool adjacentDestination = (_currentIndex! - _controller!.previousIndex).abs() == 1;
1650+
if (adjacentDestination) {
1651+
_warpToAdjacentTab(_controller!.animationDuration);
1652+
} else {
1653+
_warpToNonAdjacentTab(_controller!.animationDuration);
16361654
}
1655+
}
16371656

1638-
final Duration duration = _controller!.animationDuration;
1639-
final int previousIndex = _controller!.previousIndex;
1640-
1641-
if ((_currentIndex! - previousIndex).abs() == 1) {
1642-
if (duration == Duration.zero) {
1643-
_pageController.jumpToPage(_currentIndex!);
1644-
return Future<void>.value();
1645-
}
1646-
_warpUnderwayCount += 1;
1647-
await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
1648-
_warpUnderwayCount -= 1;
1649-
1650-
if (mounted && widget.children != _children) {
1651-
setState(() { _updateChildren(); });
1652-
}
1653-
return Future<void>.value();
1657+
Future<void> _warpToAdjacentTab(Duration duration) async {
1658+
if (duration == Duration.zero) {
1659+
_jumpToPage(_currentIndex!);
1660+
} else {
1661+
await _animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
16541662
}
1663+
if (mounted) {
1664+
setState(() { _updateChildren(); });
1665+
}
1666+
return Future<void>.value();
1667+
}
16551668

1669+
Future<void> _warpToNonAdjacentTab(Duration duration) async {
1670+
final int previousIndex = _controller!.previousIndex;
16561671
assert((_currentIndex! - previousIndex).abs() > 1);
1672+
1673+
// initialPage defines which page is shown when starting the animation.
1674+
// This page is adjacent to the destination page.
16571675
final int initialPage = _currentIndex! > previousIndex
16581676
? _currentIndex! - 1
16591677
: _currentIndex! + 1;
1660-
final List<Widget> originalChildren = _childrenWithKey;
1661-
setState(() {
1662-
_warpUnderwayCount += 1;
16631678

1679+
setState(() {
1680+
// Needed for `RenderSliverMultiBoxAdaptor.move` and kept alive children.
1681+
// For motivation, see https://github.com/flutter/flutter/pull/29188 and
1682+
// https://github.com/flutter/flutter/issues/27010#issuecomment-486475152.
16641683
_childrenWithKey = List<Widget>.of(_childrenWithKey, growable: false);
16651684
final Widget temp = _childrenWithKey[initialPage];
16661685
_childrenWithKey[initialPage] = _childrenWithKey[previousIndex];
16671686
_childrenWithKey[previousIndex] = temp;
16681687
});
1669-
_pageController.jumpToPage(initialPage);
16701688

1689+
// Make a first jump to the adjacent page.
1690+
_jumpToPage(initialPage);
1691+
1692+
// Jump or animate to the destination page.
16711693
if (duration == Duration.zero) {
1672-
_pageController.jumpToPage(_currentIndex!);
1694+
_jumpToPage(_currentIndex!);
16731695
} else {
1674-
await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
1696+
await _animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
1697+
}
16751698

1676-
if (!mounted) {
1677-
return Future<void>.value();
1678-
}
1699+
if (mounted) {
1700+
setState(() { _updateChildren(); });
16791701
}
1702+
}
16801703

1681-
setState(() {
1682-
_warpUnderwayCount -= 1;
1683-
if (widget.children != _children) {
1684-
_updateChildren();
1685-
} else {
1686-
_childrenWithKey = originalChildren;
1687-
}
1688-
});
1704+
void _syncControllerOffset() {
1705+
_controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0);
16891706
}
16901707

16911708
// Called when the PageView scrolls
16921709
bool _handleScrollNotification(ScrollNotification notification) {
1693-
if (_warpUnderwayCount > 0) {
1710+
if (_warpUnderwayCount > 0 || _scrollUnderwayCount > 0) {
16941711
return false;
16951712
}
16961713

16971714
if (notification.depth != 0) {
16981715
return false;
16991716
}
17001717

1701-
_warpUnderwayCount += 1;
1718+
_scrollUnderwayCount += 1;
17021719
if (notification is ScrollUpdateNotification && !_controller!.indexIsChanging) {
1703-
if ((_pageController.page! - _controller!.index).abs() > 1.0) {
1720+
final bool pageChanged = (_pageController.page! - _controller!.index).abs() > 1.0;
1721+
if (pageChanged) {
17041722
_controller!.index = _pageController.page!.round();
17051723
_currentIndex =_controller!.index;
17061724
}
1707-
_controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0);
1725+
_syncControllerOffset();
17081726
} else if (notification is ScrollEndNotification) {
17091727
_controller!.index = _pageController.page!.round();
17101728
_currentIndex = _controller!.index;
17111729
if (!_controller!.indexIsChanging) {
1712-
_controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0);
1730+
_syncControllerOffset();
17131731
}
17141732
}
1715-
_warpUnderwayCount -= 1;
1733+
_scrollUnderwayCount -= 1;
17161734

17171735
return false;
17181736
}

packages/flutter/test/material/tabs_test.dart

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,65 @@ void main() {
11201120
expect(position.pixels, 800);
11211121
});
11221122

1123+
testWidgets('TabBarView animation can be interrupted', (WidgetTester tester) async {
1124+
const Duration animationDuration = Duration(seconds: 2);
1125+
final List<String> tabs = <String>['A', 'B', 'C'];
1126+
1127+
final TabController tabController = TabController(
1128+
vsync: const TestVSync(),
1129+
length: tabs.length,
1130+
animationDuration: animationDuration,
1131+
);
1132+
await tester.pumpWidget(boilerplate(
1133+
child: Column(
1134+
children: <Widget>[
1135+
TabBar(
1136+
tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(),
1137+
controller: tabController,
1138+
),
1139+
SizedBox(
1140+
width: 400.0,
1141+
height: 400.0,
1142+
child: TabBarView(
1143+
controller: tabController,
1144+
children: const <Widget>[
1145+
Center(child: Text('0')),
1146+
Center(child: Text('1')),
1147+
Center(child: Text('2')),
1148+
],
1149+
),
1150+
),
1151+
],
1152+
),
1153+
));
1154+
1155+
expect(tabController.index, 0);
1156+
1157+
final PageView pageView = tester.widget<PageView>(find.byType(PageView));
1158+
final PageController pageController = pageView.controller;
1159+
final ScrollPosition position = pageController.position;
1160+
1161+
expect(position.pixels, 0.0);
1162+
1163+
await tester.tap(find.text('C'));
1164+
await tester.pump(const Duration(milliseconds: 10)); // TODO(bleroux): find why this is needed.
1165+
1166+
// Runs the animation for half of the animation duration.
1167+
await tester.pump(const Duration(seconds: 1));
1168+
1169+
// The position should be between page 1 and page 2.
1170+
expect(position.pixels, greaterThan(400.0));
1171+
expect(position.pixels, lessThan(800.0));
1172+
1173+
// Switch to another tab before the end of the animation.
1174+
await tester.tap(find.text('A'));
1175+
await tester.pump(const Duration(milliseconds: 10)); // TODO(bleroux): find why this is needed.
1176+
await tester.pump(animationDuration);
1177+
expect(position.pixels, 0.0);
1178+
1179+
await tester.pumpAndSettle(); // Finish the animation.
1180+
});
1181+
11231182
testWidgets('TabBarView viewportFraction sets PageView viewport fraction', (WidgetTester tester) async {
11241183
const Duration animationDuration = Duration(milliseconds: 100);
11251184
final List<String> tabs = <String>['A', 'B', 'C'];

0 commit comments

Comments
 (0)