PageView parallax animations in Flutter
Onboarding animations are a great way to introduce users to our product. What if we want our users to interact with the animation while we introduce them to our services/features ? This can really lead to a very engaging experience.
We’ll try to develop it with flutter, a ground-breaking cross platform framework, I have been learning recently. Animations in flutter are really easy to implement unlike the native Android. I will only be developing it for two screens, however the concept can easily be extended to multiple screens, with Flutter’s widgets. Let’s get into it.
TLDR; You can find the source here.
Breaking down the parallax animation
First part of developing any animation always revolves around decomposing it into smaller independent animations.
At first glance, this looks like Android’s Shared Element Transition, where the Flutter logo is shared across multiple screens. In Flutter, you can achieve shared element transitions using Hero animations.
However, this isn’t the case clearly. Unlike hero/shared element animations, the expected animation is Implicit, wherein the user is in complete control of the animation, and there are constraints for every element involved. Also, unlike explicit animations, which involve animation controllers and are controlled by the duration of the animation, expected animation is independent of duration.
Let’s look at the transition of the Flutter logo closely. If you are unaware with parallax animations, you would think that the logo is moving to its right. However, it’s the movement of background with respect to logo, which makes it look like its moving to its right. Here’s the transition without the background.
So we want the logo to
- translate from top-left to the centre of the available area
- and change its radius
- move along a path (linear, quadratic bezier, cubic is discussed later) to translate from initial to final point
all the while user is swiping to the next page. However, in the original animation, this logo is a part of a grid/list. How do we animate a grid item ? Let’s check that later in the article.
Other secondary animations expected:
- Other icons in the grid should move by some offset when user swipes, to create a parallax effect. (Get it ?)
- The royal crown should slide in from the right and end its transition along the vertical central axis of the available area, while the user is swiping
Building the UI
- PageView for building the user journey in pages, which are swipe-able. First Page will be a grid of icons and second with just decoration and image.
- Grid of icons can easily be built using Flutter’s GridView and basic flutter widgets like Image, Center
- Rounded Decoration to build the Second page decoration using BoxDecoration
Flutter widget documentation is really comprehensive, interactive (running samples on web as on a Dartpad), and you can directly clone the samples from the documentation.
Now that we have broken it down, let’s get into some code and figure out what will we need in order to accomplish this.
How do we apply shared element transition to a list item ?
We don’t.
In order to simplify the transformation of widget across pages, we apply the transition to an Overlay, which floats over all these pages, making it a part of every page conceivable. This overlay’s state should change based on user swipe. ie.
- When user starts swiping it should be at the top left part of screen with zero background radius
- When user ends swiping it should have translated to centre of available area and transformed background into circle
How do we get the position of initial/final state of shared element ?
We add a placeholder container in place of actual shared element in the first and second page. We can then interpolate between initial and final
- position
- size
of the logo.
In order to get the global position and size of share elements, we can either
- go the hard way
- or use a flutter package Rect Getter
This just involves wrapping the container with RectGetter widget supplying a GlobalKey, and later fetching the Rect using the supplied key.
RectGetter(key: _sharedElementKey, child: Container());RectGetter.getRectFromKey(_sharedElementKey)!;
How do we hook the transition of these elements to user swipe action ?
What you need here is to tweak the existing PageView to provide not just how much user has swiped, but also what page user is currently on.
void _onScroll() {
if (_pageController.page!.toInt() == _pageController.page || _pageController.page!.toInt() < _previousPage) {
_previousPage = _pageController.page!.toInt();
}
// Notify widgets
}
This listener can now be registered with the PageView’s controller. Source
How do we notify the widgets of change in PageView state ?
You would need a state emitter which can be listened to by widgets easily. Flutter provides us with classes like
- ValueNotifier — Holds a single value. Widgets get notified whenever there is a change in state.
- ValueListenable — Only allows the widgets to read and listen the current state.
So our PageView can send the current page state to the notifier which the widgets can listen to.
How do we animate the widgets on every user interaction ?
All the widgets whose constraints/positions needs to change on every user interaction should be rebuilt as soon as we are notified of page state change.
However, we should remember that we do not make the entire sub-tree dependent on change in value of state. Those widgets, not dependent on state, should not be built again when the state changes. They should be built only once.
Builder classes like
already provide us a way to build a part of widget subtree once, while animating the other part by changing its state.
In our case, the size of icons in grid do not change, so they should only be built once. Source attached below.
// Source for translating icons in grid by offsetContainer(
color: color,
child: Center(
child: AnimatedBuilder(
animation: listenable, // ValueListenable from ValueNotifier
builder: (context, child) { // Widget built again on state
final offsetX = // calculate offset based on page
final opacity = // calculate opacity based on page
return Opacity(
opacity: opacity,
child: Transform.translate(
offset: Offset(offsetX, 0),
child: child, // Same Icon Widget built initially with AnimatedBuilder
),
);
},
child: Image.asset( // Icon Widget built once as it's static
"assets/"+assetName+".png",
width: 50,
height: 50,
),
),
),
);
Opacity and Transform.translate widgets need to be built again, but Image does not.
These small details are really necessary to obtain great performance from animations.
How do we translate the icon from initial rect to final rect ? What path should it take ?
If you look closely at the expected animation, the Flutter logo does not move linearly, from initial to final Rect. The path it takes can be achieved using a Quadratic Bezier Curve.
// Source for above path
Path _getPathToCenter(Rect rect, Offset centre) {
return Path()
..moveTo(rect.center.dx, rect.center.dy) // Starting point
..quadraticBezierTo(
rect.center.dx*1.5, rect.center.dy, _finalOffset.dx, _finalOffset.dy); // Control Point is ahead of screen centre
}// Source for evaluating element position based on page scroll
Offset _getOffsetFor(double value, Path path) {
if (_notifier.value.previousPage == 0) {
PathMetric pathMetric = path.computeMetrics().elementAt(0);
value = value * pathMetric.length;
Tangent tangent = pathMetric.getTangentForOffset(value)!;
return tangent.position;
}
...
The Path & PathMetrics API in flutter is quite similar to Android’s Path API.
The PathMetrics API is used to evaluate the position in a Path at any given progress value ie. zero value is the starting point of Path and one corresponds to end point.
For more info on Paths in Flutter, check out this article.
Also, in order to interpolate between size/offset/radius of elements, I have used Linear Interpolation methods provided by Flutter.
Size.lerp(initialSize, finalSize, _notifier.value.pageProgress)
lerpDouble(initialRadius, finalRadius, _notifier.value.pageProgress)
You can then use Positioned, SizedBox, Transform.Translate widgets to perform these transitions.
Ensuring state consistency on page end
The value of page progress varies from 0 to 0.9xxx for page 1 and 1 to 1.9xxx for page 2. In order to have a continuous animation, we need to make sure that state of widget when a page ends (0.9xxx) is the same as the start of next page (1), otherwise the animation will break at these intervals. It’s the reason why we need to keep track of previous page.
Conclusion
All these references should be enough for you to understand the code behind this animation and build your own onboarding animation. You can tweak with Path or add more pages. You can also add transformations to background pages in PageView.
I had an amazing experience building this with Flutter APIs. No major surprises. There were some minor hiccups that I faced like:
- Hot reload not working for assets immediately
- Unable to translate text as its size can be dynamic. Unable to identify its size.
- Unable to use screen size to determine screen centre, due to native overlays like status bar etc.
However, these hiccups were insignificant when considering the overall developer experience.