Why Flutter animations need a vsync/TickerProvider
An article about Ticker and TickerProvider
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
For a clock, AnimationController doesn’t seem very useful:
- This is a perpetual animation.
The fact that a controller requires aduration
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:
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.
- Ticker is the refresh rate of our animations. This is what we want to pause when our clock is hidden.
- TickerProvider, usually implemented with SingleTickerProviderStateMixin,
is a binding between Ticker and external factors.
This is what will pause our Tickers.
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:
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 useawait 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
:
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:
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
Share this post
Twitter
Google+
Facebook
Reddit
LinkedIn
Email