Skip to content

Make TabController animateTo() method work as expected#60080

Closed
giantsol wants to merge 22 commits intoflutter:masterfrom
giantsol:tabcontroller_animation
Closed

Make TabController animateTo() method work as expected#60080
giantsol wants to merge 22 commits intoflutter:masterfrom
giantsol:tabcontroller_animation

Conversation

@giantsol
Copy link
Contributor

@giantsol giantsol commented Jun 23, 2020

Description

There are three parts that move in TabBar and TabBarView when we call tabController.animateTo():

  1. TabBar's indicator
  2. TabBar's scroll position (if it's scrollable)
  3. TabBarView's scroll position

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
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'),
  ];

  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: () {
                      _tabController.animateTo(0, duration: const Duration(seconds: 5));
                    },
                  ),
                ],
              ),
            );
          }).toList(),
        ),
      ),
    );
  }
}

Related Issues

Fixes #55625

Tests

Modified existing tests:

  • tabs_test.dart: Overflowing RTL tab bar
  • tabs_test.dart: TabBarView scrolls end close to a new page
  • tabs_test.dart: TabBarView scrolls end close to a new page with custom physics

Added new tests:

  • tabs_test.dart: TabController animation stops immediately when user starts dragging TabBarView
  • tabs_test.dart: TabBar and TabBarView should follow TabController's animation duration and curve

Checklist

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.

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I signed the CLA.
  • I read and followed the Flutter Style Guide, including Features we expect every widget to implement.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I updated/added relevant documentation (doc comments with ///).
  • All existing and new tests are passing.
  • The analyzer (flutter analyze --flutter-repo) does not report any problems on my PR.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

Did any tests fail when you ran them? Please read Handling breaking changes.

@fluttergithubbot fluttergithubbot added f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels. labels Jun 23, 2020
@chunhtai chunhtai self-requested a review June 25, 2020 23:51
Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Will have a look at this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume all the changes regarding to _originalChildrenWithKey is due to the drag interruption?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's right.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would happen prior to this pr?

Copy link
Contributor Author

@giantsol giantsol Jul 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is, if we click the tab, TabBar always gets interrupted. If we keep the condition in _handleTabControllerAnimationTick the same, only TabBarView will not get interrupted and it will look like below:

However by changing the condition, we make both of them move to the same destination:

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Contributor Author

@giantsol giantsol Jul 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Piinks Piinks added a: animation Animation APIs f: scrolling Viewports, list views, slivers, etc. labels Jul 6, 2020
@giantsol
Copy link
Contributor Author

@chunhtai
I may need some help to keep this PR going. Unfortunately, I cannot find a way to make TabBarView uninterruptible. I've investigated for quite some time, but I still couldn't understand inner workings in ScrollActivity, ScrollableState etc :(
Making stronger link between controllers also doesn't look easy. The two controller values don't always match, and there's already lots of logic to deal with these edge cases. So the way I took was just to link the duration and curve without touching those logic. I think this wouldn't result in any serious timing issue you're concerned about because logic itself is the same as before.

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.

@chunhtai
Copy link
Contributor

I think there are two ways we can do this.
1 Use ignorePointer in the tabview build method. Something like this should work

    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,
        ),
      )
    );
  1. Change the API of scroll controller to disable the scroll explicitly.

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.

@giantsol
Copy link
Contributor Author

@chunhtai
Oh~ thanks for the hint! I'll give it a shot.

@giantsol
Copy link
Contributor Author

@chunhtai
Sorry I did a force-push by mistake, but nothing in previous commits have changed. Added 2 new commits regarding code review :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would happen prior to this pr?


void _handleTabControllerAnimationTick() {
if (_warpUnderwayCount > 0 || !_controller.indexIsChanging)
if (_warpUnderwayCount > 0 && _controller.index == _currentIndex)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this still needed?

Copy link
Contributor Author

@giantsol giantsol Jul 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@giantsol
Copy link
Contributor Author

giantsol commented Jul 19, 2020

@chunhtai
I've found another problem while working on a change. We can block user gesture during animation to prevent weird jumps happening, but what if we call tabController.animateTo(..) or tabController.index = .. during the animation? Weird jumps will happen again in that case unless we also block those calls during the animation, which doesn't sound good to me.

@giantsol
Copy link
Contributor Author

@chunhtai
I've made tab click not interrupt the animation, but kept the logic related with interruption (e.g. _originalChildrenWithKey) because they are still needed when tabController.animateTo() is called while animation is running. Now, user doesn't have a way to interrupt the animation, only code can interrupt it.

Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible that the tab controller has already been animate for a period of time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@giantsol
Copy link
Contributor Author

giantsol commented Aug 3, 2020

@chunhtai
I tried implementing onAnimationStartedListener, but it doesn't fit well in current architecture. It introduces some edge cases when used together with notifyListeners() in TabController, and it only gets messier if I try to somehow resolve these cases. I think keeping ongoingXX is a better choice for now, what do you think?

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.

@chunhtai
Copy link
Contributor

chunhtai commented Aug 3, 2020

@chunhtai
I tried implementing onAnimationStartedListener, but it doesn't fit well in current architecture. It introduces some edge cases when used together with notifyListeners() in TabController, and it only gets messier if I try to somehow resolve these cases. I think keeping ongoingXX is a better choice for now, what do you think?

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?

@giantsol
Copy link
Contributor Author

giantsol commented Aug 4, 2020

@chunhtai
It was hard to explain by word, so I made a temporary commit showcasing AnimationStartedListener (note, this commit has bugs).
There were many things to consider. First, since we would want AnimationStartedListener to be called only when we call tabController.animateTo(), we should keep using _handleTabControllerTick() and _handleTabControllerAnimationTick() to keep TabBar and TabBarView in sync with the controller. But this calls for bug because we call _notifyAnimationStartedListeners() right after notifyListeners() and TabBar will have already responded to _handleTabControllerTick() ignoring incoming _handleTabControllerAnimationStarted(). But we can't remove notifyListeners() because other people may have been using it.

Another approach is to call _notifyAnimationStartedListeners() not only when animateTo() is called but also when set index is called and let TabBar and TabBarView only listen to this callback (removing _handleTabControllerTick()). But this also has problems because for instance, when user drags TabBarView and scroll settles, _handleScrollNotification() is called with ScrollEndNotification and inside, it calls _controller.index = ??. This will call _notifyAnimationStartedListeners() again. Although it will have no impact to the UI (it is already at the target index), it will notify other listeners users could have registered.

I couldn't find appropriate way to mitigate all these problems :( Will be waiting for your comment!

@chunhtai
Copy link
Contributor

chunhtai commented Aug 4, 2020

_handleTabControllerTick

I am confused a little bit. There are three things here

  1. tabcontroller.animationStartedListener
  2. tabcontroller.listener
  3. tabcontroller.animation.listener

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 user drags TabBarView and scroll settles may cause a ballistic scroll and notify the animation start again, but we already have the issue before and we solved it by guarded it with _warpUnderwayCount. I wonder if we can do the same thing here?

I maybe missing some key problem here. Let me know what you think

@giantsol
Copy link
Contributor Author

giantsol commented Aug 4, 2020

Yes, we can guard TabBarView from responding to animation again when ballistic scroll ends, but we cannot stop TabController from notifying AnimationStartedListener because it will just be called when _controller.index = ? is called. If other users have added AnimationStartedListener to the tabController, they will be notified when ScrollEndNotification happens.
We could perhaps guard that by creating a new api like setIndex(0, notify = false) and call _controller.setIndex(0, notify = false) when ScroollEndNotification happens, but I don't think that's a good idea.

@@ -216,6 +219,10 @@ class TabController extends ChangeNotifier {
_index = value;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean doing the check here

if (_index == value)
   return;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

We were already doing it here! :)

// 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above comment, we already had that check but still went all the way to notifying AnimationStartedListener, so we need this check.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this true? if you set the index directly, the duration should be null which should cause a warping effect on the tabbarview

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also mention the duration maybe null, and what does it mean to be null

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, the way I implemented it was to pass in kTabScrollDuration and Curves.ease to AnimationStartedListener when we're just setting the index:
image

So the duration and curve given to AnimationStartedListener are effectively NonNull. Would it be better we pass in null instead?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since now the animation maybe called with null duration which is not really an animation, maybe we should find a better name here....

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about IndexChangeStartedListener? I wonder if there's any naming standard in Flutter 🤔

}
}

typedef AnimationStartedListener = void Function(Duration duration, Curve curve);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's make it takes in a destination index as well.

if (_controllerIsValid) {
_controller.animation.removeListener(_handleTabControllerAnimationTick);
_controller.removeListener(_handleTabControllerTick);
_controller.removeAnimationStartedListener(_handleTabControllerAnimationStarted);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may need to take care of where duration is null

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@giantsol
Copy link
Contributor Author

giantsol commented Aug 7, 2020

@chunhtai
Applied code review and removed redundant comments! Thanks!

Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duration and curve may be null, which indicates the index is updated without any animation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!


// Not close enough to switch to page 2
pageController.jumpTo(500.0);
await tester.pumpAndSettle();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this additional pump?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I thought tester.pumpAndSettle() is needed to really end the test by finishing all the animations going on. Isn't this needed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is ok to end the test in the middle of animation unless there are something specific you want to test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

// because we tried to send a notification on dispose.
});

testWidgets('TabController animation should not get interrupted when user drags TabBarView', (WidgetTester tester) async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you also add a test to make sure tap a tab can not interrupt the animation?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also it will be perfect if you can add another test about the clipping bug you fixed as part of this pr

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

@giantsol
Copy link
Contributor Author

I had to re-add _handleTabControllerTick in this commit because of the bug that TabBar's selected color doesn't get updated properly:

I haven't looked deep but this bug is related with not calling setState in TabBar correctly. It happened after we started to use _handleTabControllerIndexChangeStarted instead of _handleTabControllerTick in TabBar. When we click a tab, tabController's animation runs. Previous to this PR, we called setState in TabBar everytime in _handleTabControllerTick i.e. before animation starts and after animation ends. However after we removed _handleTabControllerTick and called setState only when animation starts, this bug happened. It looks like we have to call setState again when animation ends, so I re-added it. Now it works like before :)

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
image

It looks like it's related with using _ChangeAnimation and _DragAnimation in line 1074 of tabs.dart

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

@giantsol giantsol Aug 11, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I applied the change you suggested locally, but the above tab text highlighting issue still persists :( I removed _handleTabControllerTick, called setState() like you mentioned.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

@chunhtai chunhtai Aug 11, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Contributor

@chunhtai chunhtai Aug 11, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is ok to end the test in the middle of animation unless there are something specific you want to test.

@giantsol
Copy link
Contributor Author

@chunhtai
There are two ways we can take about the tab highlighting issue.

First way is using _handleTabControllerTick and rebuilding the tabs before and after the animation. Based on current logic, it is necessary we rebuild the tabs after the animation ends. When animation starts, only the previous tab and target tab get _ChangeAnimation, so only two of them lerp highlights as the animation moves on. When animation finishes and tabs are rebuilt (i.e. _controller.indexIsChanging has become false), we apply _DragAnimation to the current tab and its closest neighbors, so that they would lerp their highlights as user drags.

Second way is only using _handleTabControllerIndexChangeStarted and rebuilding the tabs only before the animation. The reason this approach caused a problem is that we rebuild the tabs only when _controller.indexIsChanging is true, so that the previous tab and the target tab gets _ChangeAnimation and just stay like that. Unlike the above case, the target tab and its neighbors don't get _DragAnimation anymore. What we can do to resolve this is setting _ChangeAnimation to the previous tab and _DragAnimation to the target tab and its neighbors all in one shot (i.e. not doing them separately based on _controller.indexIsChanging). That's what I did here. Everything works fine except one difference. Since we now apply _DragAnimation to the target tab's neighbors right away, while we animate to the target tab, the left or right tab will also be highlighted on its way like below:

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 _ChangeAnimation. This change is necessary if we shall only use _handleTabControllerIndexChangeStarted to rebuild the tabs, because now we have only one time to apply animations to the tabs. Tell me what you think about this :)

BTW, I did a small cleanup in this commit. I saw no reason we should have _ChangeAnimation and _DragAnimation separately (they both work on _controller.animation.value) so I merged them into _TabChangeAnimation.

@chunhtai
Copy link
Contributor

@chunhtai
There are two ways we can take about the tab highlighting issue.

First way is using _handleTabControllerTick and rebuilding the tabs before and after the animation. Based on current logic, it is necessary we rebuild the tabs after the animation ends. When animation starts, only the previous tab and target tab get _ChangeAnimation, so only two of them lerp highlights as the animation moves on. When animation finishes and tabs are rebuilt (i.e. _controller.indexIsChanging has become false), we apply _DragAnimation to the current tab and its closest neighbors, so that they would lerp their highlights as user drags.

Second way is only using _handleTabControllerIndexChangeStarted and rebuilding the tabs only before the animation. The reason this approach caused a problem is that we rebuild the tabs only when _controller.indexIsChanging is true, so that the previous tab and the target tab gets _ChangeAnimation and just stay like that. Unlike the above case, the target tab and its neighbors don't get _DragAnimation anymore. What we can do to resolve this is setting _ChangeAnimation to the previous tab and _DragAnimation to the target tab and its neighbors all in one shot (i.e. not doing them separately based on _controller.indexIsChanging). That's what I did here. Everything works fine except one difference. Since we now apply _DragAnimation to the target tab's neighbors right away, while we animate to the target tab, the left or right tab will also be highlighted on its way like below:

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 _ChangeAnimation. This change is necessary if we shall only use _handleTabControllerIndexChangeStarted to rebuild the tabs, because now we have only one time to apply animations to the tabs. Tell me what you think about this :)

BTW, I did a small cleanup in this commit. I saw no reason we should have _ChangeAnimation and _DragAnimation separately (they both work on _controller.animation.value) so I merged them into _TabChangeAnimation.

So the issue is because the tabbar is not rebuilt before and after the animation.
I suggest we use an animationStatusListener. This achieve the same result as _handleTabTick

_controller.animation.addStatusListener(_handleTabControllerAnimationStatusChanged);
void _handleTabControllerAnimationStatusChanged(AnimationStatus status) {
    setState(() {
    });
  }

Although they looks the same, this is more programmatically correct because we create a hack to make _handleTabTick call before and after an animation.

@giantsol
Copy link
Contributor Author

I tried your suggestion, but unfortunately there seems to be a timing issue :(
I get this error when I call setState from _handleTabControllerAnimationStatusChanged:
image

It's from an assertion in _DragAnimation.

@chunhtai
Copy link
Contributor

I tried your suggestion, but unfortunately there seems to be a timing issue :(
I get this error when I call setState from _handleTabControllerAnimationStatusChanged:
image

It's from an assertion in _DragAnimation.

It was working fine for me last time i tried, you shouldn't need to setState in _handleTabControllerAnimationStatusChanged if you add animation status listener. Do you have a code that you see this issue?

@giantsol
Copy link
Contributor Author

giantsol commented Aug 14, 2020

Sure, I pushed a commit using _handleTabControllerAnimationStatusChanged so you could test with it. If you run below code with my current branch you can see the error:

Code
void 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 :)

@chunhtai
Copy link
Contributor

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);
    }
  }

@giantsol
Copy link
Contributor Author

giantsol commented Aug 16, 2020

@chunhtai
I've applied the change you suggested and checked it working properly!
I see no specific problems with current implementation :)
Thanks!

@chunhtai
Copy link
Contributor

chunhtai commented Aug 18, 2020

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.

@giantsol
Copy link
Contributor Author

@chunhtai
Thanks, I read the messages you had with Hixie. Of course we should follow what the team agreed on, but it looks like it would require another big change. I presume all the logic related with whether we should follow tabController.animation.value or not should all move into the new private class you mentioned.

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 IndexChangeStartedListener api which could soon be deprecated (if anyone starts working on a big change). There seems to be no package private stuff in dart, so I guess there's no way we could stop exposing IndexChangeStartedListener :(

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.

@chunhtai
Copy link
Contributor

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.

@giantsol
Copy link
Contributor Author

@chunhtai
Then I guess I should hand this over to someone else :) I studied a lot about TabController, TabBar and TabBarView along the way, and I'd be happy to help if anyone would need help on this. I would work on some small bug fixes we've encountered here though! I'll be sending new PRs for them soon.

Thank you so much for helping me out this far. It was my pleasure :)

@giantsol giantsol closed this Aug 21, 2020
@giantsol giantsol deleted the tabcontroller_animation branch August 22, 2020 12:47
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Aug 22, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

a: animation Animation APIs f: material design flutter/packages/flutter/material repository. f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tabController animateTo duration has no effect on duration

6 participants