Unverified Commit 5d10cc28 authored by Bruno Leroux's avatar Bruno Leroux Committed by GitHub

Fix TabBarView and TabBar animations are not synchronized (#122021)

parent 27aa83b4
...@@ -1529,10 +1529,10 @@ class TabBarView extends StatefulWidget { ...@@ -1529,10 +1529,10 @@ class TabBarView extends StatefulWidget {
class _TabBarViewState extends State<TabBarView> { class _TabBarViewState extends State<TabBarView> {
TabController? _controller; TabController? _controller;
late PageController _pageController; late PageController _pageController;
late List<Widget> _children;
late List<Widget> _childrenWithKey; late List<Widget> _childrenWithKey;
int? _currentIndex; int? _currentIndex;
int _warpUnderwayCount = 0; int _warpUnderwayCount = 0;
int _scrollUnderwayCount = 0;
bool _debugHasScheduledValidChildrenCountCheck = false; bool _debugHasScheduledValidChildrenCountCheck = false;
// If the TabBarView is rebuilt with a new tab controller, the caller should // If the TabBarView is rebuilt with a new tab controller, the caller should
...@@ -1568,6 +1568,22 @@ class _TabBarViewState extends State<TabBarView> { ...@@ -1568,6 +1568,22 @@ class _TabBarViewState extends State<TabBarView> {
} }
} }
void _jumpToPage(int page) {
_warpUnderwayCount += 1;
_pageController.jumpToPage(page);
_warpUnderwayCount -= 1;
}
Future<void> _animateToPage(
int page, {
required Duration duration,
required Curve curve,
}) async {
_warpUnderwayCount += 1;
await _pageController.animateToPage(page, duration: duration, curve: curve);
_warpUnderwayCount -= 1;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
...@@ -1591,10 +1607,10 @@ class _TabBarViewState extends State<TabBarView> { ...@@ -1591,10 +1607,10 @@ class _TabBarViewState extends State<TabBarView> {
if (widget.controller != oldWidget.controller) { if (widget.controller != oldWidget.controller) {
_updateTabController(); _updateTabController();
_currentIndex = _controller!.index; _currentIndex = _controller!.index;
_warpUnderwayCount += 1; _jumpToPage(_currentIndex!);
_pageController.jumpToPage(_currentIndex!);
_warpUnderwayCount -= 1;
} }
// 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) { if (widget.children != oldWidget.children && _warpUnderwayCount == 0) {
_updateChildren(); _updateChildren();
} }
...@@ -1611,12 +1627,11 @@ class _TabBarViewState extends State<TabBarView> { ...@@ -1611,12 +1627,11 @@ class _TabBarViewState extends State<TabBarView> {
} }
void _updateChildren() { void _updateChildren() {
_children = widget.children;
_childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children); _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children);
} }
void _handleTabControllerAnimationTick() { void _handleTabControllerAnimationTick() {
if (_warpUnderwayCount > 0 || !_controller!.indexIsChanging) { if (_scrollUnderwayCount > 0 || !_controller!.indexIsChanging) {
return; return;
} // This widget is driving the controller's animation. } // This widget is driving the controller's animation.
...@@ -1626,71 +1641,73 @@ class _TabBarViewState extends State<TabBarView> { ...@@ -1626,71 +1641,73 @@ class _TabBarViewState extends State<TabBarView> {
} }
} }
Future<void> _warpToCurrentIndex() async { void _warpToCurrentIndex() {
if (!mounted) { if (!mounted || _pageController.page == _currentIndex!.toDouble()) {
return Future<void>.value(); return;
} }
if (_pageController.page == _currentIndex!.toDouble()) { final bool adjacentDestination = (_currentIndex! - _controller!.previousIndex).abs() == 1;
return Future<void>.value(); if (adjacentDestination) {
_warpToAdjacentTab(_controller!.animationDuration);
} else {
_warpToNonAdjacentTab(_controller!.animationDuration);
} }
}
final Duration duration = _controller!.animationDuration; Future<void> _warpToAdjacentTab(Duration duration) async {
final int previousIndex = _controller!.previousIndex; if (duration == Duration.zero) {
_jumpToPage(_currentIndex!);
if ((_currentIndex! - previousIndex).abs() == 1) { } else {
if (duration == Duration.zero) { await _animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
_pageController.jumpToPage(_currentIndex!);
return Future<void>.value();
}
_warpUnderwayCount += 1;
await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
_warpUnderwayCount -= 1;
if (mounted && widget.children != _children) {
setState(() { _updateChildren(); });
}
return Future<void>.value();
} }
if (mounted) {
setState(() { _updateChildren(); });
}
return Future<void>.value();
}
Future<void> _warpToNonAdjacentTab(Duration duration) async {
final int previousIndex = _controller!.previousIndex;
assert((_currentIndex! - previousIndex).abs() > 1); 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 final int initialPage = _currentIndex! > previousIndex
? _currentIndex! - 1 ? _currentIndex! - 1
: _currentIndex! + 1; : _currentIndex! + 1;
final List<Widget> 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<Widget>.of(_childrenWithKey, growable: false); _childrenWithKey = List<Widget>.of(_childrenWithKey, growable: false);
final Widget temp = _childrenWithKey[initialPage]; final Widget temp = _childrenWithKey[initialPage];
_childrenWithKey[initialPage] = _childrenWithKey[previousIndex]; _childrenWithKey[initialPage] = _childrenWithKey[previousIndex];
_childrenWithKey[previousIndex] = temp; _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) { if (duration == Duration.zero) {
_pageController.jumpToPage(_currentIndex!); _jumpToPage(_currentIndex!);
} else { } else {
await _pageController.animateToPage(_currentIndex!, duration: duration, curve: Curves.ease); await _animateToPage(_currentIndex!, duration: duration, curve: Curves.ease);
}
if (!mounted) { if (mounted) {
return Future<void>.value(); setState(() { _updateChildren(); });
}
} }
}
setState(() { void _syncControllerOffset() {
_warpUnderwayCount -= 1; _controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0);
if (widget.children != _children) {
_updateChildren();
} else {
_childrenWithKey = originalChildren;
}
});
} }
// Called when the PageView scrolls // Called when the PageView scrolls
bool _handleScrollNotification(ScrollNotification notification) { bool _handleScrollNotification(ScrollNotification notification) {
if (_warpUnderwayCount > 0) { if (_warpUnderwayCount > 0 || _scrollUnderwayCount > 0) {
return false; return false;
} }
...@@ -1698,21 +1715,22 @@ class _TabBarViewState extends State<TabBarView> { ...@@ -1698,21 +1715,22 @@ class _TabBarViewState extends State<TabBarView> {
return false; return false;
} }
_warpUnderwayCount += 1; _scrollUnderwayCount += 1;
if (notification is ScrollUpdateNotification && !_controller!.indexIsChanging) { 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(); _controller!.index = _pageController.page!.round();
_currentIndex =_controller!.index; _currentIndex =_controller!.index;
} }
_controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0); _syncControllerOffset();
} else if (notification is ScrollEndNotification) { } else if (notification is ScrollEndNotification) {
_controller!.index = _pageController.page!.round(); _controller!.index = _pageController.page!.round();
_currentIndex = _controller!.index; _currentIndex = _controller!.index;
if (!_controller!.indexIsChanging) { if (!_controller!.indexIsChanging) {
_controller!.offset = clampDouble(_pageController.page! - _controller!.index, -1.0, 1.0); _syncControllerOffset();
} }
} }
_warpUnderwayCount -= 1; _scrollUnderwayCount -= 1;
return false; return false;
} }
......
...@@ -1120,6 +1120,65 @@ void main() { ...@@ -1120,6 +1120,65 @@ void main() {
expect(position.pixels, 800); expect(position.pixels, 800);
}); });
testWidgets('TabBarView animation can be interrupted', (WidgetTester tester) async {
const Duration animationDuration = Duration(seconds: 2);
final List<String> tabs = <String>['A', 'B', 'C'];
final TabController tabController = TabController(
vsync: const TestVSync(),
length: tabs.length,
animationDuration: animationDuration,
);
await tester.pumpWidget(boilerplate(
child: Column(
children: <Widget>[
TabBar(
tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(),
controller: tabController,
),
SizedBox(
width: 400.0,
height: 400.0,
child: TabBarView(
controller: tabController,
children: const <Widget>[
Center(child: Text('0')),
Center(child: Text('1')),
Center(child: Text('2')),
],
),
),
],
),
));
expect(tabController.index, 0);
final PageView pageView = tester.widget<PageView>(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 { testWidgets('TabBarView viewportFraction sets PageView viewport fraction', (WidgetTester tester) async {
const Duration animationDuration = Duration(milliseconds: 100); const Duration animationDuration = Duration(milliseconds: 100);
final List<String> tabs = <String>['A', 'B', 'C']; final List<String> tabs = <String>['A', 'B', 'C'];
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment