Make TabController animateTo() method work as expected#60080
Make TabController animateTo() method work as expected#60080giantsol wants to merge 22 commits intoflutter:masterfrom giantsol:tabcontroller_animation
Conversation
chunhtai
left a comment
There was a problem hiding this comment.
I feel the way the pr tries to sync the duration and curve between tabcontroller and scrollcontroller is a little bit brittle. Especially the duration is depends on the start time. can we think of a way to make a more strong link between these two controllers instead of going through tabview?
There was a problem hiding this comment.
_lastDuration is not a good way to keep track an on going animation. Maybe use _onGoingAnimationDuration, and the property should be null when there is not animation on going. to prevent the previous animation accidentally leak to the next one
There was a problem hiding this comment.
Sounds good. Will have a look at this.
There was a problem hiding this comment.
The gif that shows interruption of the animateTo looks worse than i expected. Both the page and tab bar have a big screw. I think we should probably make it uninterruptible.
There was a problem hiding this comment.
Ok, the only thing that changed in this PR was to make TabBar get interrupted. TabBarView was always getting interrupted because Scrollable widget inside TabBarView was cancelling the animation.
If we should make them all uninterruptible, I guess I should do something somewhere in Scrollable widget. I'll have a look at it.
There was a problem hiding this comment.
I presume all the changes regarding to _originalChildrenWithKey is due to the drag interruption?
There was a problem hiding this comment.
Yep, that's right.
There was a problem hiding this comment.
I remembered _originalChildrenWithKey is still needed while we don't support drag interruption. For instance, we're moving from 3rd tab to 1st tab with 10 seconds of duration, and if we click the 3rd tab, it still interrupts the animation. So we need to keep the original children in global.
There was a problem hiding this comment.
what would happen prior to this pr?
There was a problem hiding this comment.
Say we're moving from 3rd tab to 1st tab with 10 seconds. That means _childenWithKey are mingled in _warpToCurrentIndex(). While the animation is running, if we click the 3rd tab, we enter _warpToCurrentIndex() again. If we don't use this global original children, what would happen is we mingle up the already mingled up _childrenWithKey, so when all the animations finish, _childrenWithKey settles in non-original order.
There was a problem hiding this comment.
There was a problem hiding this comment.
ah I see, so the tabbarview will not get interrupt while tabbar will. what if we make the tabbar uninterruptible? (by do a condition check in _handleTap)
There was a problem hiding this comment.
I can surely do that, but then user will have no way to interrupt the animation if it's taking long. Would that be okay?
There was a problem hiding this comment.
I believe that is the original intention but we only do it partially. I am open to make it interruptible, however, I can't think of a good way to make the animation to look ok(no weird jump or sudden content changes).
There was a problem hiding this comment.
I also think there's no way to make the interrupted animation look ok as of now. Then I think it'd be better we make TabBar click also uninterruptible.
|
@chunhtai Perhaps any help I can get? I don't want to leave this PR hanging around bothering you, but also feels too complicated to resolve on my own. Thanks. |
|
I think there are two ways we can do this. return IgnorePointer(
ignoring: _warpUnderwayCount > 0,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: PageView(
dragStartBehavior: widget.dragStartBehavior,
controller: _pageController,
physics: widget.physics == null
? _kTabBarViewPhysics.applyTo(const ClampingScrollPhysics())
: _kTabBarViewPhysics.applyTo(widget.physics),
children: _childrenWithKey,
),
)
);
The 1 is probably a better approach if there is no unanticipated problem, which i can't think of any at the moment. However, ignorePointer has given me a lot of problem in the past. So I kinda hesitate to use it. The 2 is the alternative but may require tons of works. I will probably go with 1 for now and see if there will be any issue. |
|
@chunhtai |
|
@chunhtai |
There was a problem hiding this comment.
what would happen prior to this pr?
|
|
||
| void _handleTabControllerAnimationTick() { | ||
| if (_warpUnderwayCount > 0 || !_controller.indexIsChanging) | ||
| if (_warpUnderwayCount > 0 && _controller.index == _currentIndex) |
There was a problem hiding this comment.
Yes, because while _warpToCurrentIndex() is running and user clicks another tab, we enter _handleTabControllerAnimationTick() and we need to allow it to go all the way to _warpToCurrentIndex() again. If not, TabBarView animation doesn't stop and doesn't reflect the new index. This is the case when _controller.index and _currentIndex are different.
This wasn't needed before because all the animations had same duration, so even without the condition I added, prior animation would finish before the new animation finishes, which is triggered by tab click, so it eventually reached _warpToCurrentIndex() again.
|
@chunhtai |
…when tabController.animteTo() is called in sequence with delay
|
@chunhtai |
chunhtai
left a comment
There was a problem hiding this comment.
overall behavior seems fine, I left some comments
| _pageController.jumpToPage(initialPage); | ||
|
|
||
| await _pageController.animateToPage(_currentIndex, duration: kTabScrollDuration, curve: Curves.ease); | ||
| await _pageController.animateToPage(_currentIndex, duration: _controller.ongoingAnimationDuration ?? kTabScrollDuration, curve: _controller.ongoingAnimationCurve ?? Curves.ease); |
There was a problem hiding this comment.
Is it possible that the tab controller has already been animate for a period of time?
There was a problem hiding this comment.
Yes, if we call tabController.animateTo(1, duration: Duration(seconds: 10)) and after 1 second we call tabController.animateTo(3) it will come in here again while tabcontroller has been animating
There was a problem hiding this comment.
In that case i think we are fine, I more worry about that we are relying on tabController to notify this method as soon as it starts animating. If not, we are risking of animation may not be in sync because we use the duration instead of the remaining time of the animation in tab controller
| if (notification is ScrollUpdateNotification && !_controller.indexIsChanging) { | ||
| if ((_pageController.page - _controller.index).abs() > 1.0) { | ||
| _controller.index = _pageController.page.floor(); | ||
| _controller.index = _pageController.page.round(); |
There was a problem hiding this comment.
This changed was needed when we allowed user drag interruption during the tab controller animation. Since we now don't allow that to happen, we can revert it back to floor(). However in a similar logic 5 lines below this we use round(), and changing floor() to round() doesn't seem to have any side effect.
There was a problem hiding this comment.
line 1344 definitely has to be round() because we want to achieve the snapping effect. I think this is ok.
| if (notification is ScrollUpdateNotification && !_controller.indexIsChanging) { | ||
| if ((_pageController.page - _controller.index).abs() > 1.0) { | ||
| _controller.index = _pageController.page.floor(); | ||
| _controller.index = _pageController.page.round(); |
There was a problem hiding this comment.
line 1344 definitely has to be round() because we want to achieve the snapping effect. I think this is ok.
| /// The duration given to [animateTo]. | ||
| /// | ||
| /// Only valid while animation is running, null otherwise. | ||
| Duration get ongoingAnimationDuration => _ongoingAnimationDuration; |
There was a problem hiding this comment.
Instead of exposing this, which may only be useful when the animation first started (if the animation is already on the flight for a while, knowing the duration alone is not enough)
I am thinking maybe the tabcontroller should expose a new api to register a new callback, something like onAnimationStarted. The callback will take in the duration, curve and index. In the tabview, it will register a callback to also start animation on _pageController
There was a problem hiding this comment.
Surely considerable. I was also not very fond of exposing ongoingAnimationDuration and ongoingAnimationCurve just for this purpose :( I'll try that out and see how it looks.
Perhaps I may also add onAnimationEnd along with onAnimationStart?
|
@chunhtai BTW, I found another bug while trying this, which was also present previous to this PR, and made a commit for that. Below was the bug: It happens when you drag TabBarView, and before it settles to new index, you quickly touch the screen again. |
nice find! That is definitely something we want to fix. For the onAnimationStartedListener, Can you elaborate more on the corner cases you were facing? |
|
@chunhtai Another approach is to call I couldn't find appropriate way to mitigate all these problems :( Will be waiting for your comment! |
I am confused a little bit. There are three things here
I took a look at your code, it looks like both TabBar and TabBarView are listening to all three of them, which causes this convoluted problem that one notification is conflicted with another. My original idea was that the TabBar stays the same and listen to (2) and (3) so it should not have any problem with this change. TabBarView only listens to (1) because this is the only thing it need in order to sync up the scroll controller with the tab controller. The only problem will be the case where you mentioned I maybe missing some key problem here. Let me know what you think |
|
Yes, we can guard |
| @@ -216,6 +219,10 @@ class TabController extends ChangeNotifier { | |||
| _index = value; | |||
There was a problem hiding this comment.
I mean doing the check here
if (_index == value)
return;
| // it was not called by ScrollEndNotification or | ||
| // index was changing before (so that TabBar and TabBarView will start a new animation with new target index.) | ||
| // This happens when controller.index = ? is called while animation was ongoing. | ||
| if (_animationController.value != _index.toDouble() || wasIndexChanging) { |
There was a problem hiding this comment.
I remember last time i check ScrollEndNotification is called controller.index = ? with the same value as controller.index. If we have the check on line 216, it should not have enter this block. Then this check is not necessary.
There was a problem hiding this comment.
Same as above comment, we already had that check but still went all the way to notifying AnimationStartedListener, so we need this check.
There was a problem hiding this comment.
In that case we still need to notify the listener, if we think like a tabcontroller, there is no reason to block this because someone does ask us to change our index.
Either we fixes the tabview to not trigger this weird case or ignore this notify.
There was a problem hiding this comment.
Ok, if we think AnimationStartedListener as IndexChangeStartedListener, I guess notifying the listener does make sense.
| } | ||
|
|
||
| /// Calls listener every time the animation starts by setting [index] or [animateTo]. | ||
| /// When setting [index], duration is always [kTabScrollDuration] and |
There was a problem hiding this comment.
is this true? if you set the index directly, the duration should be null which should cause a warping effect on the tabbarview
There was a problem hiding this comment.
we should also mention the duration maybe null, and what does it mean to be null
There was a problem hiding this comment.
There was a problem hiding this comment.
I think we need to put null to correctly reflect what is actually happening inside tabcontroller. it is up to the observers to decide how to react to null duration(for example giving it kTabScrollDuration)
| /// [TabBarView.children]'s length. | ||
| final int length; | ||
|
|
||
| final ObserverList<AnimationStartedListener> _animationStartedListeners = ObserverList<AnimationStartedListener>(); |
There was a problem hiding this comment.
Since now the animation maybe called with null duration which is not really an animation, maybe we should find a better name here....
There was a problem hiding this comment.
How about IndexChangeStartedListener? I wonder if there's any naming standard in Flutter 🤔
| } | ||
| } | ||
|
|
||
| typedef AnimationStartedListener = void Function(Duration duration, Curve curve); |
There was a problem hiding this comment.
let's make it takes in a destination index as well.
| if (_controllerIsValid) { | ||
| _controller.animation.removeListener(_handleTabControllerAnimationTick); | ||
| _controller.removeListener(_handleTabControllerTick); | ||
| _controller.removeAnimationStartedListener(_handleTabControllerAnimationStarted); |
There was a problem hiding this comment.
Ah I didn't notice there is also a scroll controller in tab bar, but it seems like we can just use the _controller.removeAnimationStartedListener(_handleTabControllerAnimationStarted); alone will be enough?
Something like..
_handleTabControllerAnimationStarted(int destinationIndex, Duration duration, Curve curve) {
if (duration == null)
duration = kTabScrollDuration;
_scrollToIndex(destinationIndex, duration, curve);
}
void _scrollToIndex(int destinationIndex, Duration duration, Curve curve) {
final double offset = _tabCenteredScrollOffset(destinationIndex);
_scrollController.animateTo(offset, duration: duration, curve: curve);
}
I think this should be enough to keep tabcontroller and scroll controller in sync so we don't need _handleTabControllerTick and _handleTabControllerAnimationTick.
Let me know if i miss something.
There was a problem hiding this comment.
First, we need _handleTabControllerAnimationTick() because tabbar scroll should keep in sync when user is dragging TabBarView. In that case, none of _handleTabControllerTick() or _handleTabControllerAnimationStarted() is called, so we need to do it in _handleTabControllerAnimationTick().
Second, we need _handleTabControllerTick() because TabController's _notifyAnimationStartedListeners() is not called when user drags and ballistic scroll ends. However, notifyListeners() is called, and we have to update TabBar's _currentIndex() when ballistic scroll ends, so we should do it in _handleTabControllerTick().
There was a problem hiding this comment.
I forgot about dragging yes, I agree _handleTabControllerAnimationTick is needed.
The _handleTabControllerTick though probably not needed if _notifyAnimationStartedListeners is called right? Although we need to figure out why the ballistic drag update does not drag the tabcontroller all the way to the index. Either case, _handleTabControllerAnimationStarted or _handleTabControllerAnimationTick should bring the scroll controller in tab bar to the right place?
There was a problem hiding this comment.
Yep, we wouldn't need _handleTabControllerTick if we call _notifyAnimationStartedListeners. I'll change this too.
| void _handleTabControllerAnimationStarted(Duration duration, Curve curve) { | ||
| if (_warpUnderwayCount > 0 && _controller.index == _currentIndex) | ||
| return; // This widget is driving the controller's animation. | ||
|
|
There was a problem hiding this comment.
we may need to take care of where duration is null
There was a problem hiding this comment.
Yep, currently the duration and curve given are effectively NonNull because I passed in default values when not present. If we decide to pass in null instead, I'll take care of it here :)
| return; // This widget is driving the controller's animation. | ||
|
|
||
| if (_controller.index != _currentIndex) { | ||
| _currentIndex = _controller.index; |
There was a problem hiding this comment.
Since we add the destination Index, let's use it here
- Change name AnimationStartedListener to IndexChangeStartedListener - Remove redundant checks and call IndexChangeStartedListener whenever index changes - Remove _handleTabControllerTick() from TabBar - Remove redundant comments
|
@chunhtai |
chunhtai
left a comment
There was a problem hiding this comment.
Overall looks great, mostly just nit picking.
| } | ||
|
|
||
| /// Calls listener every time the index starts changing by setting [index] or [animateTo]. | ||
| /// When setting [index], duration and curve are null. |
There was a problem hiding this comment.
The duration and curve may be null, which indicates the index is updated without any animation.
|
|
||
| // Not close enough to switch to page 2 | ||
| pageController.jumpTo(500.0); | ||
| await tester.pumpAndSettle(); |
There was a problem hiding this comment.
why do we need this additional pump?
There was a problem hiding this comment.
This was needed because of the changed condition in _handleTabControllerIndexChangeStarted. Previously, it always returned when _warpUnderwayCount > 0. But now we allow it to be interrupted when current warping index and new target index is different, so when _controller.index = _pageController.page.round() is called in ScrollEndNotification, _warpToCurrentIndex was called and it ran a meaningless _pageController.animateToPage once. This animation gets cancelled right away, so it doesn't do anything to UI but it kinda "dragged" a frame.
This is a bug because we actually don't need to run _warpToCurrentIndex in this case. So I changed the order of setting _controller.index and _currentIndex here so that it wouldn't call unnecessary warping again.
| expect(tabController.animation.value, 1.0); | ||
| expect(tabController.indexIsChanging, true); | ||
|
|
||
| await tester.pumpAndSettle(); |
There was a problem hiding this comment.
Oh I thought tester.pumpAndSettle() is needed to really end the test by finishing all the animations going on. Isn't this needed?
There was a problem hiding this comment.
It is ok to end the test in the middle of animation unless there are something specific you want to test.
| // because we tried to send a notification on dispose. | ||
| }); | ||
|
|
||
| testWidgets('TabController animation should not get interrupted when user drags TabBarView', (WidgetTester tester) async { |
There was a problem hiding this comment.
can you also add a test to make sure tap a tab can not interrupt the animation?
There was a problem hiding this comment.
also it will be perfect if you can add another test about the clipping bug you fixed as part of this pr
- Change addIndexChangeStartedListener docs - Add two tests
…rags TabBarView and settles to new index
|
I had to re-add I haven't looked deep but this bug is related with not calling |
| void _scrollToCurrentIndex(Duration duration, Curve curve) { | ||
| final double offset = _tabCenteredScrollOffset(_currentIndex); | ||
| _scrollController.animateTo(offset, duration: kTabScrollDuration, curve: Curves.ease); | ||
| _scrollController.animateTo(offset, duration: duration ?? kTabScrollDuration, curve: curve ?? Curves.ease); |
There was a problem hiding this comment.
_scrollController.animateTo returns a future, we can add a promise after it finishes to set state so that we don't need to use _handleTabControllerTick for this.
There was a problem hiding this comment.
Hm I've tried that, but it seems we have to call setState after tabController.indexIsChanging has become false. We call _scrollController.animateTo slightly earlier than tabController's _animationController.animateTo, so when I try to call setState after above future returns, it emits below error:

It looks like it's related with using _ChangeAnimation and _DragAnimation in line 1074 of tabs.dart
There was a problem hiding this comment.
We need to rebuild right before the animation start to update _DragAnimation into _ChangeAnimation.
Adding the setState prior should does the trick
void _handleTabControllerIndexChangeStarted(int index, Duration duration, Curve curve) {
if (index != _currentIndex) {
setState(() {
_currentIndex = index;
});
if (widget.isScrollable)
_scrollToCurrentIndex(duration, curve);
}
}
This works because animation always starts on the next frame so the setState will make it just in time to update the _ChangeAnimation at the end of this frame.
The tab highlight on the other hand is a bit weird, it looks like there is some weird issue around how we turn on and off the selected using _buildStyledTab around line 1084 to line 1093. I have not look into too deep what the problem is. but I bet there is something wrong inside how we lerp the text style in line 183
There was a problem hiding this comment.
I applied the change you suggested locally, but the above tab text highlighting issue still persists :( I removed _handleTabControllerTick, called setState() like you mentioned.
There was a problem hiding this comment.
Yeah, I think the highlight issue is probably related to how we do the animation lerping in _TabStyle. Sorry i have been a little persist on getting things sorted out. We have put in too much bandage fixes to a point that no one really know how it works exactly :(
It will be great if we can clean it up a little bit now.
There was a problem hiding this comment.
take the TabController._changeIndex for example. It makes zero sense that we need to notify the listener twice before and after animation ends in line 223 and line 229. The comment does not make sense either. However, that is required to update the _DragAnimation into _ChangeAnimation in TabBar and updates it back from _ChangeAnimation into _DragAnimation. This feels like a code smell because a random listener may be notified for no reason
There was a problem hiding this comment.
No problem :) I understand sorting things out is really important. I also agree it needs cleanup, but maybe that should be done apart from this PR? What do you think?
There was a problem hiding this comment.
yes, we don't need to clean up everything in this pr, i think the last thing we need to figure out to merge this pr is what cause the highlight issue, and if we can fix it without _handleTabControllerTick. I am ok with using _handleTabControllerTick if it is unavoidable as long as we know the reason behind it.
To me it seems like we have everything we need by using the _handleTabControllerIndexChangeStarted
| expect(tabController.animation.value, 1.0); | ||
| expect(tabController.indexIsChanging, true); | ||
|
|
||
| await tester.pumpAndSettle(); |
There was a problem hiding this comment.
It is ok to end the test in the middle of animation unless there are something specific you want to test.
|
@chunhtai First way is using Second way is only using If you look closely, while we animate from Tab 3 -> Tab 1, Tab 2 also lerps highlighting. Previously, Tab 2 wouldn't have lerped because only Tab 1 and Tab 3 would have BTW, I did a small cleanup in this commit. I saw no reason we should have |
So the issue is because the tabbar is not rebuilt before and after the animation. Although they looks the same, this is more programmatically correct because we create a hack to make _handleTabTick call before and after an animation. |
…ollerIndexChangeStarted" This reverts commit db8400a.
|
Sure, I pushed a commit using Codevoid main() {
runApp(Main());
}
class Main extends StatefulWidget {
Main({Key key}) : super(key: key);
@override
_MainState createState() => _MainState();
}
class _MainState extends State<Main> with SingleTickerProviderStateMixin {
final List<Tab> _tabs = <Tab>[
Tab(text: 'Tab 1'),
Tab(text: 'Tab 2'),
Tab(text: 'Tab 3'),
Tab(text: 'Tab 4'),
];
TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(vsync: this, length: _tabs.length);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
bottom: TabBar(
controller: _tabController,
tabs: _tabs,
isScrollable: true,
labelPadding: const EdgeInsets.symmetric(horizontal: 56),
),
),
body: TabBarView(
controller: _tabController,
children: _tabs.map((Tab tab) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(tab.text),
RaisedButton(
child: Text('animate to 0 in 5 seconds'),
onPressed: () async {
_tabController.animateTo(0, duration: const Duration(seconds: 5));
},
),
],
),
);
}).toList(),
),
),
);
}
}Try clicking Tab 3, then swipe to Tab 2 and then click Tab 3 again. If you use swipe and click a tab a few times you will see the error :) |
|
Thanks for the repro, I think you can fix this with void _handleTabControllerAnimationStatusChanged(AnimationStatus status) {
switch(status) {
case AnimationStatus.completed:
setState(() {
// Rebuild the tabs after a (potentially animated) index change has
// completed to update the tab animation back to _DragAnimation.
});
return;
case AnimationStatus.reverse:
case AnimationStatus.dismissed:
case AnimationStatus.forward:
return;
}
}
void _handleTabControllerIndexChangeStarted(int index, Duration duration, Curve curve) {
if (index != _currentIndex) {
setState(() {
// Updates the the tab animation to _ChangeAnimation.
});
_currentIndex = index;
if (widget.isScrollable)
_scrollToCurrentIndex(duration, curve);
}
} |
|
@chunhtai |
|
Hi @giantsol, sorry for the late reply. I had a discussion with the team, and the general consent is we should not rely on duration and curve of the tabcontroller to drive scrollcontroller of both tabbar and tabbarview because the duration and curve can change mid flight. That means the IndexChangeStarted is not going to work. We will have to write some special logic to be able to drive the scrolling using the tab.animation. Fortunately, the tabbar and tabbarview owns the page controller and scroll controller, we should be able to write an private class of these two controllers to accept an animation value to listen to and creates their own scrollposition that will animate based on the animation value. The full discussion can be found in our discord flutter-framework channel, I will post the transcript here too https://gist.github.com/chunhtai/0574c29e79b26e54e42fdea39063aa50 let me know if you have any question. |
|
@chunhtai However, I doubt I could work on the new change you're suggesting. I guess it'd require a lot more time, but I just can't afford that. Stepping back, this PR's main goal was to fix the bug that tabController.animateTo wasn't working properly, and it actually does fix that. So I wanted to suggest we complete this PR and make it a new task for the new path you mentioned, but the problem is we'd be exposing the new What do you think? There's been a lot going on in this PR. I guess we'd have to either find a way to merge this and then move on, or close this and let someone else work on it anew. Thanks. |
|
Hi @giantsol We should not merge a temporary solution, but I think we can reuse part of this pr because there are a lot of other fixes in this PR that are parallel from the indexchangestart listener. I am happy to keep working through the new solution with you or have someone else take over it. Let me know what you think. We are very thankful for your contribution either way. |
|
@chunhtai Thank you so much for helping me out this far. It was my pleasure :) |








Description
There are three parts that move in TabBar and TabBarView when we call tabController.animateTo():
Currently, none of them properly follow the duration and curve given to tabController.animateTo(). I previously worked on a PR with only one fix among the three here, but we've agreed it should rather be a breaking change and fix all of them together. So this PR is a re-work with all the three fixed along with a breaking change proposal.
Before
Called
tabController.animateTo(0, duration: const Duration(seconds: 5)), so the animation should finish after 5 seconds. However, you can see all the three elements above move much faster than that. Also, it doesn't look natural when I interrupt the animation by dragging the TabBarView.After
TabController animation's duration and curve are ensured. Also, when user interrupts the animation, all the moving parts stops immediately altogether.
Code
Related Issues
Fixes #55625
Tests
Modified existing tests:
tabs_test.dart: Overflowing RTL tab bartabs_test.dart: TabBarView scrolls end close to a new pagetabs_test.dart: TabBarView scrolls end close to a new page with custom physicsAdded new tests:
tabs_test.dart: TabController animation stops immediately when user starts dragging TabBarViewtabs_test.dart: TabBar and TabBarView should follow TabController's animation duration and curveChecklist
Before you create this PR confirm that it meets all requirements listed below by checking the relevant checkboxes (
[x]). This will ensure a smooth and quick review process.///).flutter analyze --flutter-repo) does not report any problems on my PR.Breaking Change
Did any tests fail when you ran them? Please read Handling breaking changes.