Tutorial hero image
Lesson icon

Using the FLIP Concept for High Performance Animations in Ionic

8 min read

Originally published April 15, 2020

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 code

FLIP (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 use getBoundingClientRect() 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.

If you enjoyed this article, feel free to share it with others!