Take Your Flutter Animations to The Next Level — Flutter Vikings Talk

Flutter Vikings Conference — Thor — August 31st 2022 — Roaa Khaddam
Flutter Vikings Conference — Thor Room — August 31st 2022 — Photo Credit: Daria Orlova 💙

On August 31st, I did a talk titled “Take Your Flutter Animations to the Next Level” at the amazing Flutter Vikings conference and in this article you’ll find all the links and read a written version of the talk!

TL; DR

The talk was about what it takes to build complex animations like this one:

Flutter Vikings Talk Animation Demo — Roaa Khaddam
Flutter Vikings Talk Animation Demo

Links

Talk Video

Talk Agenda

In this article, we will go over the following topics that were discussed in the talk:

  • Animating ScrollView Items: Scrolling animations with lists, grids, and slivers.
  • Complex Hero animations
  • Gyroscope effect with an alternative for non-mobile devices.

This is how the animation looks like on web & desktop (large screens)

Flutter Vikings Talk Animation preview on Desktop and web (large screens)
Animation Preview on Desktop & Web

You can see that the list is now a grid and instead of a Gyroscope effect in the recipe page, moving the mouse will move the shadows and pattern behind the recipe image, with respect to the current location of the mouse.

Now let’s see what it takes to build such an animation…

1. Animating ScrollView Items

1. Animating ScrollView Items — Flutter Vikings Talk Slide
1. Animating ScrollView Items — Flutter Vikings Talk
ScrollView widgets builders and cacheExtent
ScrollView widgets builders and cacheExtent

ScrollView widgets like ListView, GridView, PageView, and so on, have an itemBuilder parameter. This itemBuilder builds its child widget when it enters the screen, and disposes it when it leaves the screen. (The CustomScrollView widget’s slivers have the same behavior)

And with the cacheExtent parameter set to zero, the widget is built when it is 0 pixels into the scroll view. Otherwise, flutter will do some calculations to estimate the cacheExtent value and it might be more than zero.

As a result, having an animation in that Child widget that runs as soon as it’s built, so in the initState widget lifecycle method, for example, will achieve that animation for us. This is what the Child widget code would look like:

class Child extends StatefulWidget {
const Child({Key? key}) : super(key: key);

@override
State<Child> createState() => _ChildState();
}

class _ChildState extends State<Child> with SingleTickerProviderStateMixin {
late AnimationController _animationController;

@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..forward();
// Animation will run when widget is built (scrolled into view)
}
//..
}

But I prefer to use the syntax below, where I create a ChildWrapper wrapper widget that can be easily reused and wrapped around scroll view children of any scrollable widget.

This way we can use the AutomaticKeepAliveStateMixin to decide whether the animation should run only once, or every time that it’s built by passing down a “keepAlive” parameter from the parent widget.

class ChildWrapper extends StatefulWidget {
const ChildWrapper({
Key? key,
required this.child,
this.keepAlive = false,
}) : super(key: key);

final bool keepAlive;
final Widget child;

@override
State<ChildWrapper> createState() => _ChildWrapperState();
}

class _ChildWrapperState extends State<ChildWrapper>
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
//...
@override
Widget build(BuildContext context) {
super.build(context);
return AnimationWidget();
}

@override
bool get wantKeepAlive => widget.keepAlive;
}

You can check out this tweet for more info about this.

RecipesPage Widget

If we look back at the animation of the recipes list, we will notice that aside from perspective & scaling transforms, the perspective direction and alignment are changing based on the scroll direction.

Animation appearance for scrolling up and down in Recipes page — Flutter Vikings Animation Talk
Animation appearance for scrolling up and down in Recipes page

To achieve that we have to in some way listen to the scroll direction of the user. We can do that by either using the scroll controller, or wrapping our recipes list with a NotificationListener of the type UserScrollNotification.

Let’s see the code of the RecipePage widget that contains the list/grid of the recipes:

Code for the RecipesPage widget of the Animation Demo Code

Here is the code breakdown:

  • We are using a GridView so that we can change the crossAxisCount (line 42) of the grid to 1 for small screens, and 3 for large screens, and we’re also changing the grid padding.
  • This is done with a RecipesLayout helper class and ScreenSize enum. And this is how a responsive grid/list is achieved.
  • To avoid setState and reduce unnecessary rebuilds, we’re using a ValueNotifier for the updated value of the scroll direction (line 17).
  • To be able to listen to scroll direction changes, we’re wrapping the GridView with a NotificationListener of the type UserScrollNotification and updating the scrollDirectionNotifier value (lines 26 => 34)
  • Inside the itemBuilder of the GridView widget (line 47), you can find the main list child on line 50, the RecipeListItem widget, and this is passed as a child parameter to the ValueListenableBuilder widget (line 48) so that it doesn’t rebuild every time the notifier value changes. And the notifier’s scrollDirection value is passed to the RecipeListItemWrapper widget (line 53) that contains the animation code and that we’ll look into shortly.

Perspective Transform

Before we see the animation code of the RecipeListItemWrapper widget, let’s see briefly how the perspective transform works in Flutter.

Perspective transform in Flutter — Flutter Vikings Animations Talk — Roaa Khaddam
Perspective Transform in Flutter

To apply a perspective transform, you can use a Transform widget and give its transform parameter a 4x4 identity matrix (Matrix4.identity()) and change its (3,0), (3, 1), or (3, 3) entries (x, y or z axis respectively).

A Transform widget with a perspective value doesn’t actually do any 3D transformation. It just scales down its child along the specified axis starting from its origin, which is specified by the alignment. The positive direction is downward, so to scale down in the upward direction, we should use a negative value.

In our demo animation we are interested in the y value. And we can change the direction of the scaling down of the perspective transform according to the scroll direction, by changing the alignment of the Transform widget from bottomCenter to topCenter and giving it a negative value for the upwards direction.

RecipeListItemWrapper Widget

Prior to getting into the RecipeListItemWrapper animation code, during the Flutter Vikings talk I talked briefly about animation concepts in Flutter, but if you are not very familiar with things like AnimationController and AnimatedBuilder, you can checkout this comprehensive guide I wrote on animations and there you will find everything you need to know to follow along with this article.

Now let’s see some animation code:

Code for the RecipeListItemWrapper widget of the Animation Demo Code

Here’s the code breakdown:

  • If you jump down to the build method of the stateful widget, you will see that we have an AnimatedBuilder widget (line 82) that takes an animationController and has the RecipeListItemWrapper widget’s child as its child parameter (line 84) so it doesn’t get unnecessarily rebuilt as the animation is running, and has 2 Transform widgets, one for the perspective animation, and another for the scale animation.
  • In lines 22 => 25, we define our late animationController and different animation variables.
  • In the initState method, in lines 32 => 62, before we give values to our animationController, and scale, perspective, and alignment animations, we define 2 helper variables that depend on the user scroll direction passed from the parent widget:
  • The perspectiveDirectionMultiplier that determines whether the scaling down direction of the perspective transform is negative or positive (line 32), and the directionAlignment variable which determines the origin alignment of the perspective transform widget (line 35).
  • Calling forward() on the animationController in the initState method (line 43) is what runs the animation when the list item scrolls into view. And that’s it!
  • You may notice that in lines 48, 58 & 68, we are using an Interval class inside a CurvedAnimation that is given as value to the animate() method called on the animation Tween. This is a concept called Staggered Animations. And we’ll talk about this in the next section.

Staggered Animations

Staggered animations are a way to overlap or run animations in separate intervals.

Staggered animations in Flutter — Flutter Vikings Talk Slide
Staggered Animations in Flutter

The idea is to give start and end values for the Interval class ranging from 0 to 1, and that animation will run according to those values out of the total duration of the animation controller. So if the total duration is one second for example and the interval is from 0 to 0.5, that specific animation is going to last half a second.

The best way to see staggered animations in action in our demo animation is the ingredients list we see when we open a recipe page.

Preview of Staggered Animations For Lists — Recipe Page Ingredients — Flutter Vikings Talk
Preview of Staggered Animations For Lists — Recipe Page Ingredients

You can see here all list items have the same animation and end at the same time, but they start consecutively based on their index. This is done easily by giving the start value of the interval of each of those animations as the list item index divided by the list length.

Staggered Animations Interval Start Value Based on List Index and length

And this is it for animating ScrollView items in Flutter easily in cool ways! Let’s get into other aspects of the demo animation and see how they can be achieved.

2. Complex Hero Animations

2. Complex Hero Animations — Flutter Vikings Talk — Roaa Khaddam
2. Complex Hero Animations — Flutter Vikings Talk

Notice in the preview above that the recipe item background, image, and text are all animating into their place in the new page, and then the elements in between, the pattern and shadow, fade in. This is simply achieved by wrapping each of these widgets with a built-in Hero widget.

For this Hero widget to work between 2 pages, it only needs to have the same tag parameter, and it works most optimally if it has the same widget tree as its child.

// First Hero
Hero(
tag: 'unique_hero_tag',
child: ChildWidget1( // Same widget tree
child: ChildWidget2(/* ... */),
),
),
// Second Hero
Hero(
tag: 'unique_hero_tag',
child: ChildWidget1( // Same widget tree
child: ChildWidget2(/* ... */),
),
),

Page Transition

By default navigation in Flutter will make the page slide in. To prevent this from obstructing our Hero animation, we can override it by adding a FadeTransition to our Navigator.of(context).push() method like so:

Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
pageBuilder:
(BuildContext context, Animation<double> animation, _) {
return RecipePage(/* ... */);
},
transitionsBuilder: (BuildContext context,
Animation<double> animation, _, Widget child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
);

This is the simplest way to do this and it can be optimized and improved but it’s a topic on its own that I would like to cover later in a dedicated tutorial.

RecipeImage Widget

To make things easier and reusable and to guarantee the conditions that make a Hero widget work (same tag and child widget tree), we create a RecipeImage widget and have the Hero widget inside it and then reuse it in all places (list item, recipe page sliver app bar (small screens), recipe page sidebar (large screens))

Rotating Image With User Scrolling

The hero animation is affected by the rotation animation that happens in the recipe page when the user scrolls. So let’s explore that first.

The RecipePage is built with a CustomScrollView that has a scrollController that we attach a listener to in order to listen to scroll events. Then we change the rotation angle of the image based on scroll position and reverse its rotation direction based on the user scroll direction.

We’re also using a ValueNotifier here for the image rotation angle to avoid using setState and reduce unnecessary rebuilds. The value of this notifier is then passed to the RecipeImage widget.

However, what matters to us now relating to the hero animation, is that when this page is popped, we should send the current rotation value back to the previous page with the pop() navigator method. Otherwise the image will snap back to its initial rotation and ruin the smooth Hero animation.

So when we pop the recipe page:

Navigator.of(context).pop(imageRotationAngle);

And in the RecipeListItem widget where we push the recipe page, we receive that value after the future of the navigator push completes and preserve it in that widget as well to give it to its the RecipeImage widget as well as send it back to the RecipePage widget.

Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
pageBuilder:
(BuildContext context, Animation<double> animation, _) {
return RecipePage(
widget.recipe,
initialImageRotationAngle: recipeImageRotationAngle,
);
},
transitionsBuilder: (BuildContext context,
Animation<double> animation, _, Widget child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
)
.then((response) {
if (response != null && response is double && mounted) {
setState(() {
recipeImageRotationAngle = response;
});
}
});

Check out the subtle but important difference in the Hero animation when we don’t send the rotation angle information back (try to focus on the cheesecake image 🍰):

Hero Animation With and Without Sending Rotation Angle Back From RecipePage — Flutter Vikings Animation Talk
Hero Animation With and Without Sending Rotation Angle Back From RecipePage

3. Gyroscope Effect

3. Gyroscope Effect — Flutter Vikings Animation Talk — Roaa Khaddam
3. Gyroscope Effect — Flutter Vikings Talk

Before we see the code from our animation, let’s see how the gyroscope works.

The GyroscopeEvent Stream

By simply adding the sensor_plus package to your app dependencies, you now have access to the device’s gyroscope data via a GyroscopeEvent stream provided by the package.

How The Gyroscope Event and Movement looks like

For our animation, we only care about the x and y axis. And as you see in the illustration above, we want the child widget of that stream to move in the x direction when the phone is moving around the y direction and vice versa. Additionally, we want the movement to be bounded in a maxMovableDistance value so that it doesn’t keep moving indefinitely. This value can be provided to the widget containing the Gyroscope effect that we will see shortly.

The GyroscopeEffect Widget

In the code snippet above we used a StreamBuilder to listen to the GyroscopeEvent stream. However, because we want to create a reusable GyroscopeEffect widget, and to take performance into account and make sure the same stream doesn’t get duplicated, I’m using Riverpod’s StreamProvider to subscribe to the GyroscopeEvent stream. I’m also using the StreamProvider to replace the GyroscopeEvent stream with an empty stream for macOS and linux.

final gyroscopeProvider = StreamProvider<GyroscopeEvent>((_) {
if (defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.linux) {
return const Stream.empty();
} else {
return SensorsPlatform.instance.gyroscopeEvents;
}
});

And this is how we use this provider in the GyroscopeEffect widget:

GyroscopeEffect Widget Code

Here is the code breakdown:

  • Riverpod’s Consumer widget is used to listen to the gyroscopeProvider (lines 50 => 54)
  • We have 2 constructors for our widget, regular and builder constructors.
  • The regular constructor has a required child parameter (line 10) that is given to the Consumer widget (line 51) then passed down into the _buildChile method on line 62, and then used as the child parameter in the TweenAnimationBuilder widget (line 86) that is returned for a regular constructor.
  • By passing the child this way whatever widget you assign it, it will not get rebuilt as the Consumer widget is rebuilding with the GyroscopeEvent stream nor with the TweenAnimationBuilder widget.
  • If the .builder named constructor is used, the widget will have a required childBuilder parameter (line 22) of the type OffsetEffectBuilder?
typedef OffsetEffectBuilder = Widget Function(
BuildContext context,
Offset offset,
Widget? child,
);
  • The OffsetEffectBuilder provides the Offset that the child should move in. This is especially useful when we want to have more customization for the animated widget or even animate multiple child widgets. The OffsetEffectBuilder also provides the child widget that can be used along with the child parameter of the GyroscopeEffect.builder() widget to reduce rebuilds.

4. MouseRegion Animation (Gyroscope Alternative)

4. MouseRegion Animation — Flutter Vikings Talk — Roaa Khaddam
4. MouseRegion Animation — Flutter Vikings Talk

As you see in the preview above, alternatively to moving the pattern and shadows with the device based on Gyroscope data, we can make use of the mouse pointer and have the pattern and shadows move based on the location (or alignment) of the mouse pointer within that area.

The MouseRegionEffect Widget

This is easily accomplished using Flutter’s built-in MouseRegion widget! It has onEnter, onHover, and onExit callbacks that provide us with helpful information from which, when we know the area’s width and height, we can calculate the alignment of the mouse pointer. And with that we can come up with a reusable MouseRegionEffect widget similar in structure to our GyroscopeEffect widget:

Here is the code breakdown:

  • Similar to the GyroscopeEffect widget, we have 2 constructors, regular and builder.
  • The regular constructor has a required child parameter (line 10) that is passed down to the TweenAnimationBuilder’s child to reduce rebuilds (line 82, 101)
  • If the .builder named constructor is used, the widget will have a required childBuilder parameter (line 23) of the type OffsetEffectBuilder?
  • The Offset value is calculated inside the onEnter, onHover, and onExit, callbacks of the MouseRegion widget (lines 69, 74, 79).
  • The calculation is done by first finding the alignment from the mouse position in the function alignmentFromOffset (line 43) and the provided area width and height.
  • Then we calculate the offset from the alignment in the offsetFromMousePosition function (line 55) based on the maxMovableDistance parameter value.

The AdaptiveOffsetEffect Widget

To make things easier and more cross-platform compatible, we can create a single AdaptiveOffsetEffect widget that based on the defaultTargetPlatform, either returns a GyroscopeEffect or a MouseRegionEffect widget. You can check out the code for this widget here:

To see examples of how this widget was used you can check out the RecipePageSidebar widget and the RecipePageSliverAppBar widget.

Conclusion

In my opinion, taking your animations to the next level takes no more than attention to details and going one step further with a normal (or boring 👀) looking animation.

For example, in the demo animation, taking normal animations one step further can be found with switching the direction of the perspective based on user scroll direction, as well as not only animating the RecipeListItemWrapper, but also the RecipeImage and the recipe text which gave more of a 3D effect.

And for attention to detail, you can take an example of the Hero animation snapping back into initial rotation if we haven’t sent back the current rotation angle when popping the RecipePage.

It is also important to keep performance in mind when doing such intensively animated UI’s. There are a lot of animation controllers and tickers in this UI and it was important to try to avoid setState when possible by using ValueNotifiers and also reduce rebuilds by using builder widgets like the AnimatedBuilder, the TweenAnimationBuilder, the Consumer widget, and eventually creating our own builder constructors for the complex GyroscopeEffect and MouseRegionEffect widgets.

Last but not least, Flutter is cross-platform, so why not make sure what you create looks great on all screens and devices? For example, in this demo, adaptability was achieved by replacing the gyroscope effect with a mouse effect when a gyroscope is not available, and responsiveness was achieved by replacing the main list with a grid and the SliverAppBar with a side bar for larger screens.

Thank you! 💙

I just wanted to end this article by saying thank you to everyone who attended my talk in person or online. It meant the world to me! And most importantly, thanks to the best conference organizer and amazing person Majid Hajian and everyone who made Flutter Vikings happen! It was a much needed offline conference where I met my heroes, the people I’ve been following and learning from for years! And I made friends and hung out with awesome people from around the world! And I’m so looking forward to the next event 🤩💙!

--

--

Get the Medium app