Using the FLIP Concept for High Performance Animations in Ionic
When animating the size or position of elements in our applications, we should (generally) only use the transform
property to achieve this. Animating anything else - like width
, height
, margin
, position
, and padding
- will result in triggering a browser "layout". This is where the browser needs to recalculate the positions of everything on the screen, which is the most expensive step in the browser rendering process in terms of performance. If we trigger this process many times in quick succession (by animating a change in these properties) we can quickly destroy the performance of the application. The extra work the browser needs to do to calculate the positions will result in slower "frames" (i.e. the result being shown to the user on screen), and if we can not achieve around 60 frames per second the animation will not feel smooth.
The reason this step is necessary for most position affecting properties is that one element changing will affect the position of other elements of the screen (e.g. reducing the height
of an element will cause other elements on the screen to shift upwards). The reason that the transform
property performs so well is that it doesn't trigger layouts (or even "paints") in the browser rendering process. Modifying the transform
property will only trigger the last step in the browser rendering process. This is where the "compositor" organises/positions/scales "layers" on the screen - all the heavy calculation work is already done and the various "layers" have their results painted onto them already, the work of the compositor is kind of like shuffling papers around. Less work for the browser to do means faster frames, and faster frames means smoother animations.
But! This is somewhat limiting. The transform
property can do a lot like scale
, rotate
, and translate
to modify an element, but sometimes we might want to make use of other positional properties in our animations. Expanding an element to be full screen is a lot easier if we can use position
to calculate the new dimensions.
Outline
Source codeFLIP (First, Last, Invert, Play)
This is where the FLIP concept comes in. FLIP is an acronym that stands for:
- First - calculate the current positions on the screen
- Last - calculate the final positions on the screen
- Invert - use transforms to modify the final positions to immitate the first positions
- Play - play the animation by removing the transforms
This concept relies on the fact that a user won't perceive that something hasn't happened as long as we respond to their input in around 100ms
. That means that we can use that initial 100ms
before anything happens on screen to perform heavy calculations for the animation, and then play the animation smoothly with transforms.
Let's consider the example where we want to use position
to animate an element to another position on the screen. We can't use position
directly for the animation as it will result in poor performance (since it will trigger layouts throughout the animation). However, what we can do is this:
- First - use
getBoundingClientRect()
to determine the current position of the element - Last - apply a class to the element that applies the appropriate
position
styles (with no animations) and then usegetBoundingClientRect()
to determine the new position of the element. - Invert - use the two position values to calculate what
transform
values need to be applied to get the element in its final position, transformed back to its original position - Play - now that we have done all the calculations, we can play the animation by animating the transform back to its initial state (e.g.
transform: scale(1, 1) translate(0, 0)
)
If you would like a more thorough introduction to using FLIP in Ionic I have a more in-depth video available: Improve Animation Performance with FLIP and the Ionic Animations API. You can also check out the original post (at least, I think it is the original) on this concept by Paul from the Google Chrome team: FLIP Your Animations.
This style of animation is great for situations where you need to dynamically calculate position values for animations, like for expanding elements to be full screen (which is what I cover in the video above).
However, I also wanted to demonstrate using this concept in another context that is also useful and not just expanding something to be a certain size on the page. In this tutorial, we will create an add-to-cart animation that will have the image for the product shrink and fly to the cart icon on the screen. We will be doing this by creating a generic component that will allow you to supply any element on the screen and have the product image fly dynamically to that position on the screen (no matter where it is). This is what it will look like:
Before We Get Started
This application was created using Ionic/StencilJS, but the methods being used (e.g. the Ionic Animations API) are also available for Angular, React, Vue, etc. If you are following along with StencilJS, I will assume that you already have a reasonable understanding of how to use StencilJS. If you are following along with a framework like Angular, React, or Vue then you will need to adapt parts of this tutorial as we go.
If you would like to explore the performance concepts in this tutorial in more detail, my Advanced Animations & Interactions with Ionic book covers this and a whole lot more. It is also available in editions for StencilJS, Angular, and React.
1. The General Concept
There are a few extra little animations in play in the GIF above to make everything look a bit nicer, but the key concept behind the animation is that we have a clone of the product image transforming its size/position as it moves toward a particular element on the screen - in this case, the cart button. We could just use a static position on the screen to animate to, but we will be creating a component that allows for any element on the screen to be supplied to determine the position. This component will be called <app-fly-to>
- if you are using StencilJS you could generate this component automatically by running npm run generate
and naming it app-fly-to
.
Using the component will look something like this:
<ion-content class="ion-padding">
{this.cards.map((card) => (
<app-fly-to>
<div class="product-card">
<img src="http://placehold.it/400" />
<p>
Keep close to Nature's heart... and break clear away, once in
awhile, and climb a mountain or spend a week in the woods. Wash
your spirit clean.
</p>
<ion-button
expand="full"
onClick={(ev) => {
this.addToCart(ev);
}}
>
Add to Cart
</ion-button>
</div>
</app-fly-to>
))}
</ion-content>,
addToCart(ev) {
const flyToElement = ev.target.closest("app-fly-to");
flyToElement.trigger(this.cartButton);
}
First, we just have a list of product cards that we are displaying in our application. To achieve the functionality we want, we will wrap our product cards in the <app-fly-to>
component. This component will provide a trigger
method, which will start the animation. All we need to do is get a reference to the relevant <app-fly-to>
component (which we are doing inside of the addToCart
method on the page) and then call its trigger
method. We supply the trigger
method with a reference to the element that we want the product image to fly to.
2. The Basics of the Component
Let's first take a look at the basic structure of the <app-fly-to>
component:
import {
Component,
Method,
Element,
ComponentInterface,
h,
} from '@stencil/core';
import { createAnimation, Animation } from '@ionic/core';
@Component({
tag: 'app-fly-to',
styleUrl: 'app-fly-to.css',
})
export class AppFlyTo implements ComponentInterface {
@Element() hostElement: HTMLElement;
@Method()
async trigger(flyTo: HTMLElement) {}
render() {
return <slot></slot>;
}
}
The structure is quite simple - we just have a <slot>
for the template which will project any template supplied inside of the <app-fly-to>
tags into the <app-fly-to>
component's template (the equivalent in Angular would be <ng-content>
). If you are unfamiliar with <slot>
and content projection you might want to read: Understanding How Slots are Used in Ionic.
We are also importing createAnimation
which we will make use of inside of the trigger
method to create our animations.
3. The FLIP Animation
Now let's get down to business, this is what the trigger method looks like when we add in our FLIP animation to move the product image to the cart button:
@Method()
async trigger(flyTo: HTMLElement) {
const elementToAnimate = this.hostElement.querySelector("img");
// First
const first = elementToAnimate.getBoundingClientRect();
const clone = elementToAnimate.cloneNode();
const clonedElement: HTMLElement = this.hostElement.appendChild(
clone
) as HTMLElement;
// Last
const flyToPosition = flyTo.getBoundingClientRect();
clonedElement.style.cssText = `position: fixed; top: ${flyToPosition.top}px; left: ${flyToPosition.left}px; height: 50px; width: 50px;`;
const last = clonedElement.getBoundingClientRect();
// Invert
const invert = {
x: first.left - last.left,
y: first.top - last.top,
scaleX: first.width / last.width,
scaleY: first.height / last.height,
};
// Play
const flyAnimation: Animation = createAnimation()
.addElement(clonedElement)
.duration(500)
.beforeStyles({
["transform-origin"]: "0 0",
["clip-path"]: "circle()",
["z-index"]: "10",
})
.easing("ease-in")
.fromTo(
"transform",
`translate(${invert.x}px, ${invert.y}px) scale(${invert.scaleX}, ${invert.scaleY})`,
"translate(0, 0) scale(1, 1)"
)
.fromTo("opacity", "1", "0.5");
flyAnimation.onFinish(() => {
clonedElement.remove();
});
flyAnimation.play();
}
NOTE: If you are using this tutorial to understand the FLIP concept, I would recommend this video instead. This tutorial is a "less standard" implementation of FLIP.
Let's take a look at each of the steps in the FLIP process happening here in detail.
First
const elementToAnimate = this.hostElement.querySelector("img");
// First
const first = elementToAnimate.getBoundingClientRect();
const clone = elementToAnimate.cloneNode();
const clonedElement: HTMLElement = this.hostElement.appendChild(
clone
) as HTMLElement;
There is an additional step here that wouldn't be typical in a FLIP animation, because we don't want to just animate the image element itself, we want to animate a copy of the image (because we still want the product image to remain on the card as well). So, after grabbing a reference to the image, we clone it with cloneNode
and append the duplicated image as another child in the component.
The actual FLIP step here is calculating the initial position of the image (its "normal" position on the card) using getBoundingClientRect which will give us the following details about the element:
x : 93
y : 50
width : 440
height : 240
top : 50
right : 533
bottom : 290
left : 93
This gives us (more than) enough information to make the position calculations we need.
Last
// Last
const flyToPosition = flyTo.getBoundingClientRect();
clonedElement.style.cssText = `position: fixed; top: ${flyToPosition.top}px; left: ${flyToPosition.left}px; height: 50px; width: 50px;`;
const last = clonedElement.getBoundingClientRect();
Now we need to apply styles to the element we are animating such that it will be in its "final" position (i.e. it should be on top of the add-to-cart icon). To determine where the element should be, we use the position of the flyTo
element that was passed in through the trigger
method. We then use the position values of that element, and apply them to the position of our cloned image element. Once the image element is in its final position, we use getBoundingClientRect()
again to take a reading of the new position.
Invert
// Invert
const invert = {
x: first.left - last.left,
y: first.top - last.top,
scaleX: first.width / last.width,
scaleY: first.height / last.height,
};
Now we use the First and Last position values we calculated to create our "invert" values. The x
and y
values determine how much the element needs to be moved (translated) in the x
and y
directions in order to be back in its original position. The scaleX
and scaleY
values determine how much bigger/smaller it needs to be. This calculation is generally the same for every FLIP animation.
Play
// Play
const flyAnimation: Animation = createAnimation()
.addElement(clonedElement)
.duration(500)
.beforeStyles({
['transform-origin']: '0 0',
['clip-path']: 'circle()',
['z-index']: '10',
})
.easing('ease-in')
.fromTo(
'transform',
`translate(${invert.x}px, ${invert.y}px) scale(${invert.scaleX}, ${invert.scaleY})`,
'translate(0, 0) scale(1, 1)'
)
.fromTo('opacity', '1', '0.5');
flyAnimation.onFinish(() => {
clonedElement.remove();
});
flyAnimation.play();
We have finished with all the heavy calculation work now (and hopefully this is all achieved well within that 100ms
limit). At this point, we just need to play the animation using transforms and the values we calculated. If you are unfamiliar with the Ionic Animations API, I would recommend watching: The Ionic Animations API.
In the fromTo
for this animation, we are animating from the inverted position - this means the element has its final styles applied, but it has been inverted back into its original position with the transform values we calculated - to the un-inverted position (the final styles are still applied, but the transforms have been animated away). We also animate the opacity to 0.5
for a bit of an extra effect, and we use a clip-path
in the beforeStyles
to make the image into a circle (this isn't necessary, I think it just looks better this way - it kind of feels like the image is being packed up and then sent off to the cart).
Another important aspect here is that when the animation is finished, we remove the cloned img
element from the DOM. This finalises the effect because we don't actually animate the image to 0
opacity, but it is also important because if we didn't remove the element from the DOM, the DOM would become littered with cloned img
elements over time.
4. Extra Animations
We have already finished the core functionality of the component, but I think it requires a few extra touches to make the animation look convincing and nice. We will add additional animations to make the product card animate its opacity as the item is being added to the cart, and we will make the "fly to" element (the cart button in this case) "pulse" as it "receives" the item being sent to it.
// Play
const opacityToggleAnimation: Animation = createAnimation()
.addElement(elementToAnimate)
.duration(200)
.easing('ease-in')
.fromTo('opacity', '1', '0.4');
const flyAnimation: Animation = createAnimation()
.addElement(clonedElement)
.duration(500)
.beforeStyles({
['transform-origin']: '0 0',
['clip-path']: 'circle()',
['z-index']: '10',
})
.easing('ease-in')
.fromTo(
'transform',
`translate(${invert.x}px, ${invert.y}px) scale(${invert.scaleX}, ${invert.scaleY})`,
'translate(0, 0) scale(1, 1)'
)
.fromTo('opacity', '1', '0.5');
const pulseFlyToElementAnimation: Animation = createAnimation()
.addElement(flyTo)
.duration(200)
.direction('alternate')
.iterations(2)
.easing('ease-in')
.fromTo('transform', 'scale(1)', 'scale(1.3)');
opacityToggleAnimation.play();
flyAnimation.onFinish(() => {
pulseFlyToElementAnimation.play();
opacityToggleAnimation.direction('reverse');
opacityToggleAnimation.play();
clonedElement.remove();
});
flyAnimation.play();
We have defined two more animations here, and we play the opacity animation both before and after the flying animation finishes, and we play the "pulse" animation just once after the flying animation finishes. An interesting aspect of the pulse animation is that we play it with two iterations
with a direction of alternate
. This means it will automatically play forward once to scale the element to 1.3x its size, and then it will play it immediately after in reverse to scale it back down to its original 1x size. This creates a convincing popping or pulsing sort of effect.
5. Using the Component
We have more or less already covered what using this component will look like, but here is an example of a full implementation for reference:
import { Component, State, Element, h } from "@stencil/core";
@Component({
tag: "app-home",
styleUrl: "app-home.css",
})
export class AppHome {
@Element() hostElement: HTMLElement;
@State() cards: string[] = ["one", "two", "three"];
public cartButton: HTMLElement;
componentDidLoad() {
this.cartButton = this.hostElement.querySelector(".cart-button");
}
addToCart(ev) {
const flyToElement = ev.target.closest("app-fly-to");
flyToElement.trigger(this.cartButton);
}
render() {
return [
<ion-header>
<ion-toolbar color="primary">
<ion-title>Home</ion-title>
<ion-buttons slot="end">
<ion-button class="cart-button">
<ion-icon name="cart"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>,
<ion-content class="ion-padding">
{this.cards.map((card) => (
<app-fly-to>
<div class="product-card">
<img src="http://placehold.it/400" />
<p>
Keep close to Nature's heart... and break clear away, once in
awhile, and climb a mountain or spend a week in the woods. Wash
your spirit clean.
</p>
<ion-button
expand="full"
onClick={(ev) => {
this.addToCart(ev);
}}
>
Add to Cart
</ion-button>
</div>
</app-fly-to>
))}
</ion-content>,
];
}
}
The end result should look like this:
Summary
Using the FLIP concept can be a great way to achieve high performance animations in Ionic or with web applications in general. In effect, it's kind of like a bit of a trick that allows you to animate other types of non-performance friendly CSS properties (i.e. those that trigger layouts) whilst still actually just using transforms behind the scenes. I think that it demonstrates well that with extra care and attention, the web is capable of a lot more than some people might think.