Why Flutter animations need a vsync/TickerProvider

An article about Ticker and TickerProvider

Rémi Rousselet

8 minute read

If you have used Flutter animations before, you likely came across a parameter named vsync or the mixin SingleTickerProviderStateMixin.
But we are rarely using them ourselves. So what do they even do?

To answer these questions, we will use a simple animation as an example: A clock

clock animation

For a clock, AnimationController doesn’t seem very useful:

  • This is a perpetual animation.
    The fact that a controller requires a duration property for the controller feels like it doesn’t fit our use-case.
  • The animation is linear.
    We don’t need things like Curve and Tween.

All in all, we may be tempted to not use AnimationController.
A naive approach could be to setState every few frames and use DateTime.now() like so:

class AnimatedClockClock extends StatefulWidget {
  @override
  _AnimatedClockClockState createState() => _AnimatedClockClockState();
}

class _AnimatedClockClockState extends State<AnimatedClockClock> {
  var running = true;

  @override
  void initState() {
    super.initState();
    // Regularly refreshed the screen to update the clock
    Future.doWhile(() async {
      setState(() {});
      await Future.delayed(const Duration(milliseconds: 50));
      return running;
    });
  }

  @override
  void dispose() {
    // Stop calling setState when the clock is removed from the widget tree
    running = false;
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // A widget that renders an unanimated clock from the current time
    return _ClockRenderer(
      dateTime: DateTime.now(),
    );
  }
}

At first glance, it seems to work (the previous gif was recorded using it after-all). But plot twist, it suffers from major drawbacks:

  • It’s not very precise.
    We need to manually control what the refresh-rate of the animation is. Too low and our screen may not show the updated “seconds” for a few frames. Too high and we are draining the power of our phone.
  • It’s hard to test. Because of Future.delayed, it becomes difficult to target for specific parts of the animation.

But more importantly, it is inefficient:

An animated widget may not be visible

You see, while our Clock widget is inside the widget tree, there is absolutely no guarantee that it is visible:

  • The Clock may be in a route that is currently not visible
  • It may be hidden because an ancestor widget such as Visibility

The problem is, in those situations, our Clock widget will still continuously call setState, which triggers unnecessary screen refreshes.

One way to highlight this problem is to enable the in-app performance overlay:

refresh animation

From this gif, we can see that even when a new page appeared on the top of our clock, our screen still keeps refreshing. This is bad.

This is why we have that vsync parameter on AnimationController,

Vsync/TickerProvider allows animations to be muted, slowed, or fast-forwarded

Be it tests, dev tools, or because the animation is no longer visible, the speed of our animation may depend on factors external to the animation itself.

Using Flutter’s animation framework, this is achieved through a Ticker + TickerProvider.

More specifically, when writing:

class Example extends StatefulWidget {
  @override
  ExampleState createState() => ExampleState();
}

class ExampleState extends State<Example> with SingleTickerProviderStateMixin {
  AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: Duration(),
      vsync: this,
    );
  }

  // ...
}

Then we are saying “the animation associated to controller can be paused when our Example widget is hidden”.

Reimplementing our Clock with Ticker/TickerProvider

Let’s fix the performance issues of our clock by using Ticker/TickerProvider to control the animation.

Creating a Ticker with TickerProvider

Our first goal is to create a Ticker. For this, we will need a TickerProvider, such as SingleTickerProviderStateMixin.

It is important to use an official TickerProvider, as we could otherwise break the “pause the animation” feature.

Don’t create one TickerProvider and reuse it for all widgets either.
Your TickerProvider should be associated with the widget that wants to perform an animation. As such, avoid storing a TickerProvider as a global/singleton or passing it with provider.

A good rule of thumb is to accept that StatefulWidget + SingleTickerProviderStateMixin is verbose, and not fight against it.

Anyway, let’s create our Ticker:

class AnimatedClock extends StatefulWidget {
  @override
  _AnimatedClockState createState() => _AnimatedClockState();
}

class _AnimatedClockState extends State<AnimatedClock>
    with SingleTickerProviderStateMixin {
  Ticker _ticker;

  @override
  void initState() {
    super.initState();
    _ticker = createTicker((Duration elapsed) {});
  }

  @override
  void dispose() {
    _ticker.dispose();
    super.dispose();
  }
}

Do not forget to call dispose on your Ticker when the state is destroyed.

Using our Ticker to keep track of the current time

Now that we have created a Ticker, let’s start using it. We will want to do two things:

  • Know what the time was when the animation started
  • Update that time on every tick based on how much time has elapsed since the beginning of the animation.

First, let’s obtain the current time when the animation starts. Nothing special here, we will simply call DateTime.now() (although we will come back to this code later for testing).

Ticker _ticker;
DateTime _startTime;
DateTime _currentTime;

@override
void initState() {
  super.initState();
  _startTime = _currentTime = DateTime.now();
  _ticker = createTicker((Duration elapsed) {});
}

Then, we will use that _currentTime variable inside our build method to render our clock:

@override
Widget build(BuildContext context) {
  return _ClockRenderer(dateTime: _currentTime);
}

Finally, we will use our Ticker to update our _currentTime variable.

This can be done with the elapsed parameter of the function passed when creating our Ticker.
That elapsed parameter represents how much time has passed since the beginning of the animation. As such, the current time is the time when the animation started + the time elapsed.

We can update our createTicker with this logic:

_ticker = createTicker((Duration elapsed) {
  setState(() {
    _currentTime = _startTime.add(elapsed);
  });
});

That’s it! At this stage, we should have a working clock.
The difference with before? Let’s do the same test (pushing a new route) with the performance overlay enabled:

updated clock gif

As you can see from this new gif, now as soon as a new route is pushed, the screen stops refreshing: Our clock animation is paused.

Writing tests for our clock

It would be a shame to stop here.
Now that we’ve used Ticker for our animation, let’s showcase how Ticker also makes your animations testable.

For this, we will use “golden tests”, which consists of taking screenshots of our widget at specific times.

A naive approach would be:

testWidgets('renders our clock', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: Center(
        child: RepaintBoundary(
          child: AnimatedClock(),
        ),
      ),
    ),
  );

  await expectLater(
    find.byType(AnimatedClock),
    matchesGoldenFile('initial_frame.png'),
  );
});

But this causes two questions:

  • Our widget relies on DateTime.now(), so the screenshot will never be the same. How can we make sure that the widget is always rendered at a specific time?

  • How do we make the animation progress?
    We can’t reasonably use await Future.delayed and make our test wait for an hour.

Mocking the time

First, we will want to mock the behavior of DateTime.now inside our tests.

For this, we can use a class called Clock, from package:clock. It’s a package made by Google developers to help with time manipulation with testability in mind.

This can be combined with provider to provide an instance of Clock to our application, such that instead of:

DateTime _startTime;
DateTime _currentTime;

@override
void initState() {
  super.initState();
  _startTime = _currentTime = DateTime.now();

we do:

DateTime _initialTime;
DateTime _now;

@override
void initState() {
  super.initState();
  _now = _initialTime = context.read<Clock>().now();

From there, we need to add a provider in our main that creates a Clock:

void main() {
  runApp(
    Provider(
      create: (_) => Clock(),
      child: MyApp(),
    ),
  );
}

Then, our tests can override this provider with a mocked Clock that returns a specific time.
Widget tests come with a built-in mocked clock, so we will use that:

testWidgets('renders our clock', (tester) async {
  await tester.pumpWidget(
    Provider(
      create: (_) => tester.binding.clock,
      child: ...,
    ),
  );

  ...
}

That’s it for mocking time!
Our test at this stage should look like this:

testWidgets('renders our clock', (tester) async {
  await tester.pumpWidget(
    Provider(
      create: (_) => tester.binding.clock,
      child: MaterialApp(
        home: Center(
          // Adding a RepaintBoundary ensure that we take a sreenshot of only
          // what we care about, not the entire screen.
          child: RepaintBoundary(
            child: AnimatedClock(),
          ),
        ),
      ),
    ),
  );

  await expectLater(
    find.byType(AnimatedClock),
    matchesGoldenFile('initial_frame.png'),
  );
});

Which generates the following initial_frame.png:

initial_frame.png

Simulating time changes

Now that we have mocked the initial frame of our test, what remains is testing the scenario where our clock updates.

What we don’t want is to use Future.delayed, as this is very inefficient.
Instead, thanks to Ticker, we will ask our test to simulate the passage of time.

This is done using tester.pump, by passing to pump how much time we want to skip.
We can then take another screenshot.

This will typically look like this:

// added at the end of our previous test

await tester.pump(Duration(hours: 2, minutes: 42, seconds: 21));

await expectLater(
  find.byType(AnimatedClock),
  matchesGoldenFile('updated_frame.png'),
);

Which generates the following screenshot:

updated_frame.png

Done!

Thanks to Ticker, we now have both fixed performance issues with our clock and we were able to write UI tests for our animation.

For the full code of both the test and the widgets, you can see the following gist:

https://gist.github.com/rrousselGit/beaf7442a20ea7e2ed3f13bbd40984a8

comments powered by Disqus