Tutorial hero
Lesson icon

High Performance Parallax Animation in Ionic 2

Originally published December 12, 2016 Time 17 mins

A little while ago, I recreated a parallax header directive that was originally created by Ola Christensson for Ionic 1. It was updated to use the Ionic 2 syntax and structure, but the general approach was basically the same.

A listener was set up for the scroll event, and as the content area was scrolled the header image was scaled up and down to create the parallax effect. In order to improve the performance, requestAnimationFrame is used so that the browser can optimise the application of the animation (rather than you deciding when to apply the animation, the browser will schedule it in a way that works best with a ton of other things that are going on at the same time).

Performance considerations are especially important for animations like this. Since we are hooking into the scroll event, which fires off at a very high rate, any operation that we perform in the handler for that event could potentially be fired hundreds of times in a small frame of time. Operations that affect the DOM (Document Object Model), for example, are quite costly for performance.

Here’s what the end result looked like:

Ionic 2 Parallax Header

It looks quite good, but that’s running through the browser. When running on a mobile device the animation often struggled (even with the use of requestAnimationFrame). After publishing this, Adam Bradley (the lead developer at Ionic) got in touch to say they were making some API changes that would make creating animations like this a lot easier, and a lot more performant.

These changes have just recently been implemented, and the Ionic team have delivered in a big way.

I rewrote the directive to make use of these performance improvements (and I also added other general improvements), and you can check out the results in this video. The animation performs well on an iPhone 7, which doesn’t come as much of a surprise, but with these performance improvements, you can also see the animation running smoothly on a cheap Android device running Android 4.1.1.

In this tutorial, I am going to walk through how to build this high-performance parallax directive for Ionic 2. Adam has also taken the time to explain to me how these performance improvements work, so I will also try to explain how these new performance features work.

Before We Get Started

Usually, I start these tutorials from scratch, right from generating the application itself, but for this tutorial we will just be creating the directive. I will walk through how to create and add the directive to an existing application.

If you’re not familiar with Ionic 2 already, I’d recommend reading my Ionic 2 Beginners Guide first to get up and running and understand the basic concepts. If you want a much more detailed guide for learning Ionic 2, then take a look at Building Mobile Apps with Ionic 2.

IMPORTANT: At the time of writing this, these new features are only available in the nightly version of Ionic 2. These changes will be included in a stable release soon (in the RC.4 release I assume), but if you need to update to the nightly release you can just run these commands:

npm install @ionic/app-scripts@nightly
npm install --save ionic-angular@nightly

You may also need to update the Angular dependencies in the package.json file accordingly.

How will it work?

Before jumping into building this directive, we should discuss exactly how the parallax header is going to work. Ideally, we want this to be simple, and require the least configuration possible.

Of course, we need some way to define what image is going to be used for the parallax header. In order to do that, this is the syntax we are going to use in our templates when making use of this directive:

<ion-content parallax-header>
  <div class="header-image"></div>

  <div class="main-content">
    <p>Content goes here</p>
  </div>
</ion-content>

All we will need to do is add the parallax-header directive to the <ion-content> element, and then we define the parallax header image and content area using two divs. We will need to define a background image using CSS on the header-image class, and place any content inside of the main-content div.

Now that we know how we want it to work, let’s start building it.

1. Create the Parallax Header Directive

We are going to start off by generating a new directive using the generate command that the Ionic CLI provides, then we will step through how to build it.

Run the following command to generate the directive:

ionic g directive ParallaxHeader

This will generate the directive in the components folder for us, but we still need to set it up in the app.module.ts file.

Modify src/app/app.module.ts to reflect the following:

import { NgModule, ErrorHandler } from '@angular/core';
import { IonicApp, IonicModule, IonicErrorHandler } from 'ionic-angular';
import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
import { ParallaxHeader } from '../components/parallax-header/parallax-header';

@NgModule({
  declarations: [MyApp, HomePage, ParallaxHeader],
  imports: [IonicModule.forRoot(MyApp)],
  bootstrap: [IonicApp],
  entryComponents: [MyApp, HomePage],
  providers: [{ provide: ErrorHandler, useClass: IonicErrorHandler }],
})
export class AppModule {}

Notice that the directive has been added to the declarations array. Unlike pages, it does not also need to be added to the entryComponents array.

2. Implement the Parallax Header Directive

Now we are going to implement the code for the directive. We are going to set it up in its entirety first, and then talk through how it works.

Modify src/components/parallax-header/parallax-header.ts too reflect the following:

import { Directive, ElementRef, Renderer } from '@angular/core';

@Directive({
  selector: '[parallax-header]',
  host: {
    '(ionScroll)': 'onContentScroll($event)',
    '(window:resize)': 'onWindowResize($event)'
  }
})
export class ParallaxHeader {

    header: any;
    headerHeight: any;
    translateAmt: any;
    scaleAmt: any;

    constructor(public element: ElementRef, public renderer: Renderer){

    }

    ngOnInit(){

        let content = this.element.nativeElement.getElementsByClassName('scroll-content')[0];
        this.header = content.getElementsByClassName('header-image')[0];
        let mainContent = content.getElementsByClassName('main-content')[0];

        this.headerHeight = this.header.clientHeight;

        this.renderer.setElementStyle(this.header, 'webkitTransformOrigin', 'center bottom');
        this.renderer.setElementStyle(this.header, 'background-size', 'cover');
        this.renderer.setElementStyle(mainContent, 'position', 'absolute');

    }

    onWindowResize(ev){
        this.headerHeight = this.header.clientHeight;
    }

    onContentScroll(ev){

        ev.domWrite(() => {
            this.updateParallaxHeader(ev);
        });

    }

    updateParallaxHeader(ev){

        if(ev.scrollTop >= 0){
            this.translateAmt = ev.scrollTop / 2;
            this.scaleAmt = 1;
        } else {
            this.translateAmt = 0;
            this.scaleAmt = -ev.scrollTop / this.headerHeight + 1;
        }

        this.renderer.setElementStyle(this.header, 'webkitTransform', 'translate3d(0,'+this.translateAmt+'px,0) scale('+this.scaleAmt+','+this.scaleAmt+')');

    }

}

Let’s first take a look at our decorator definition:

@Directive({
  selector: '[parallax-header]',
  host: {
    '(ionScroll)': 'onContentScroll($event)',
    '(window:resize)': 'onWindowResize($event)'
  }
})

We set up a selector for the parallax-header attribute, which allows us to add the directive to an element like we did with <ion-content> just before, and then we set up the host property with an object.

What this host property does is listen for output from the parent component, that is the component that the directive is attached to. So, if we are adding this directive to <ion-content> then this will allow us to listen for output coming from <ion-content>. In this case, we are listening for the ionScroll event, and when we detect it we will trigger the onContentScroll function in our directive. We also listen for the resize event and trigger the onWindowResize event when that happens.

Now let’s take a look at the ngOnInit function:

ngOnInit(){

        let content = this.element.nativeElement.getElementsByClassName('scroll-content')[0];
        this.header = content.getElementsByClassName('header-image')[0];
        let mainContent = content.getElementsByClassName('main-content')[0];

        this.headerHeight = this.header.clientHeight;

        this.renderer.setElementStyle(this.header, 'webkitTransformOrigin', 'center bottom');
        this.renderer.setElementStyle(this.header, 'background-size', 'cover');
        this.renderer.setElementStyle(mainContent, 'position', 'absolute');

    }

This function is automatically triggered when the DOM is ready, and we use it to set up some references, and also to set up some initial styling on certain elements. We set up a reference to the content area, and then we use that to get references to the header-image and main-content divs that we will define in the template.

We also use the Renderer to set some styling we need for the parallax effect to work correctly. The reason we use the Renderer, rather than just manipulate the styles directly, is because this provides us a platform agnostic way to change styles. Angular 2 is designed to work in multiple environments, not just the web, so it is better practice to alter styles this way.

Let’s move on.

onWindowResize(ev){
        this.headerHeight = this.header.clientHeight;
    }

    onContentScroll(ev){

        ev.domWrite(() => {
            this.updateParallaxHeader(ev);
        });

    }

These are the two events that we set up the listeners for in host. The first is quite simple, in order for us to apply the parallax effect correctly we need to know how tall the header area is. When the screen size changes, this is going to affect the headers size, so we update the height appropriately when we detect a resize event.

The onContentScroll event is triggered whenever we receive a scroll event, and this function contains the most important bit of code in the entire directive. We need to call the updateParallaxHeader() function to apply the parallax effect as the content area scrolls, but instead of just calling it directly, we call it inside of domWrite.

This domWrite function is one of the major improvements that the Ionic team has implemented, which (along with the updated ionScroll event) allows us to achieve such smooth animation performance even on an old Android device.

Using the ionScroll event, rather than setting up a normal scroll listener, ensures that the values will be read from the DOM at the correct time such that they don’t cause jankiness in the scrolling. When attempting to read values like this you can trigger a layout, which is a process the browser performs to calculate the size and location of elements on the page, which can be a costly operation. Using ionScroll ensures that unnecessary layouts aren’t triggered.

Similarly, domWrite ensures that anything that needs to be written to the DOM is done at the most opportune time. This is similar in theory to using requestAnimationFrame for animations, which essentially allows you to tell the browser “I want to make this update, do it at the best time” rather than “I want to make this update, DO IT NOW!“. However, the domWrite function allows all the components from the app to queue up writes to the DOM, and it will perform those writes in the most performant way possible. It will handle performing all DOM reads from across the app first, and then all of the DOM writes.

I had a quick chat to Adam Bradley about this new feature, and he notes an important discovery for this performance improvement:

Under the hood, it is using requestAnimationFrame, except it's organizing the reads and writes to prevent layout thrashing. What I noticed was that we were correctly doing DOM reads/writes per component, but across the entire app and various components, we could still have issues by all of them not being organized.

This organisation is what allows for the smooth performance. So, if you are writing to the DOM, make sure to do it inside of domWrite. Not doing so would be like slamming your brakes on the freeway, you’ll disturb the nice harmonious flow of traffic.

With all of that theory out the way, let’s move on to the final function.

updateParallaxHeader(ev){

        if(ev.scrollTop >= 0){
            this.translateAmt = ev.scrollTop / 2;
            this.scaleAmt = 1;
        } else {
            this.translateAmt = 0;
            this.scaleAmt = -ev.scrollTop / this.headerHeight + 1;
        }

        this.renderer.setElementStyle(this.header, 'webkitTransform', 'translate3d(0,'+this.translateAmt+'px,0) scale('+this.scaleAmt+','+this.scaleAmt+')');

    }

This function is what we are calling inside of domWrite, and as you can see we are updating the header element in the DOM. All this function does is calculate the new values required for the parallax effect (based on how far the user has scrolled) and then applies them.

3. Add the Required CSS

Most of the important CSS is applied automatically by the directive, but we will still need to define a few styles wherever we want to use this so that it will display nicely.

Add the following CSS to the template that is making use of the directive:

.header-image {
  background-image: url(https://ununsplash.imgix.net/photo-1421091242698-34f6ad7fc088?fit=crop&fm=jpg&h=650&q=75&w=950);
  height: 40vh;
}

.main-content {
  padding: 20px;
  background-color: white;
}

We, of course, set up a background image for the header, and then also give it a height of 40% of the viewport height. You can modify this to your liking. Using the parallax header means we can’t use Ionic’s built in padding utility attribute (it would also add padding to the header image, which we don’t want), so you may also want to add your own padding to the new main-content area.

4. Add the Directive to a Template

Finally, all that is left to do is add the directive to <ion-content> and create the appropriate divs:

<ion-content parallax-header>
  <div class="header-image"></div>

  <div class="main-content">
    <h2>Parallax Header</h2>

    <p>
      Lorem Ipsum is simply dummy text of the printing and typesetting industry.
      Lorem Ipsum has been the industry's standard dummy text ever since the
      1500s, when an unknown printer took a galley of type and scrambled it to
      make a type specimen book. It has survived not only five centuries, but
      also the leap into electronic typesetting, remaining essentially
      unchanged. It was popularised in the 1960s with the release of Letraset
      sheets containing Lorem Ipsum passages, and more recently with desktop
      publishing software like Aldus PageMaker including versions of Lorem
      Ipsum.
    </p>

    <p>
      Lorem Ipsum is simply dummy text of the printing and typesetting industry.
      Lorem Ipsum has been the industry's standard dummy text ever since the
      1500s, when an unknown printer took a galley of type and scrambled it to
      make a type specimen book. It has survived not only five centuries, but
      also the leap into electronic typesetting, remaining essentially
      unchanged. It was popularised in the 1960s with the release of Letraset
      sheets containing Lorem Ipsum passages, and more recently with desktop
      publishing software like Aldus PageMaker including versions of Lorem
      Ipsum.
    </p>

    <p>
      Lorem Ipsum is simply dummy text of the printing and typesetting industry.
      Lorem Ipsum has been the industry's standard dummy text ever since the
      1500s, when an unknown printer took a galley of type and scrambled it to
      make a type specimen book. It has survived not only five centuries, but
      also the leap into electronic typesetting, remaining essentially
      unchanged. It was popularised in the 1960s with the release of Letraset
      sheets containing Lorem Ipsum passages, and more recently with desktop
      publishing software like Aldus PageMaker including versions of Lorem
      Ipsum.
    </p>

    <p>
      Lorem Ipsum is simply dummy text of the printing and typesetting industry.
      Lorem Ipsum has been the industry's standard dummy text ever since the
      1500s, when an unknown printer took a galley of type and scrambled it to
      make a type specimen book. It has survived not only five centuries, but
      also the leap into electronic typesetting, remaining essentially
      unchanged. It was popularised in the 1960s with the release of Letraset
      sheets containing Lorem Ipsum passages, and more recently with desktop
      publishing software like Aldus PageMaker including versions of Lorem
      Ipsum.
    </p>
  </div>
</ion-content>

I’ve added a bunch of dummy content if you want to copy and paste this example to see how it looks.

IMPORTANT: If you want to run this on iOS you should install the WKWebView plugin otherwise scroll events won’t be received whilst the list is scrolling (resulting in a wonky header).

Summary

One of the drawbacks of using a hybrid web approach like Ionic is that it has typically been difficult to create smooth animations on low-end devices, like the 4.1.1 Android device I used in the demonstration video.

This parallax header directive is a great test of Ionic’s performance. It faces many of the challenges that can cause heavy performance hits to an application, and it handles them smoothly. I’ve been working with hybrid mobile apps for a long time now, and have tested many apps on that old and cheap Android device that I own, and I am blown away by how smooth the animation runs in Ionic 2.

I also hope that this tutorial highlights how important it is to consider performance when building your applications. Whether you are building native, hybrid, or web apps, poor design is always going to lead to poor performance. You could easily build this directive without making use of ionScroll and domWrite, and it would work, but the performance would be nowhere near as good. It would be easy, and understandable, to come to the conclusion that Ionic is slow based on that poor performance, when in fact the poor performance is due to poor design.

Aside from being a good chance to discuss performance, and some of the concepts that surround that, the parallax directive is also just a really cool and simple feature that you can add to your apps.

Learn to build modern Angular apps with my course