Parallax in Ionic 2

High Performance Parallax Animation in Ionic 2



·

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/[email protected]
npm install --save [email protected]

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.

What to watch next...

  • Phil Lindsay

    Another great tutorial Josh 🙂 Just a quick question, what would be the best way of implementing this if you wanted to reference the background image in the template, instead of the CSS? Add an inline style to the ‘header-image’ div, overriding the ‘background-image’ property?

    • adesinamark

      Yes. This is what I did

      And the CSS like so:
      .header-image {
      height:45vh;
      background-size: contain !important;
      background-repeat: no-repeat;
      background-position: center center;
      }

      • Valery Patrizia Madiedo Gómez

        ¿how can i do this if i have to build the url ( for example whatever.com/{{ product.image.big }}/image.jpg )?

      • adesinamark
      • Valery Patrizia Madiedo Gómez

        I mean, when I call the json from my api, I only get the id of the item and I have to use the id to complete the url. When I use the tag img i do it like this
        so i can include the image id to the url. ¿How can i do this with the background image?

      • Valery Patrizia Madiedo Gómez

        Well if anybody else have this question, what you have to do is to set the url in a variable in the typescript and then pass it on:
        [style.background-image]=”‘url(‘ + image + ‘)'”
        i got the answer here
        https://forum.ionicframework.com/t/change-style-background-image-dynamically/52002/3

      • adesinamark

        Try this:

  • Awesome, I always wanted to see how should I develop something like that,
    I would like to see more tutorial about animations. Specially like what they have done in here `http://ionicmaterial.com/demo/`

  • Joost Van Doremalen

    Thanks for this directive; it is performing nicely.

    I could find 2 issues on iOS though: when letting the header ‘bounce back’ from a position where it is scaled up, no ionScroll events are triggered (only when the bounce is finished), which causes the header image to not scale smoothy. Furthermore, there top part of the header image does not remain neatly fixed when scaling up.

    Is there a way to disable the ‘bounce’ effect on ios?

  • napcat

    If I scroll down to fast I see some white pixeis on the top of the image. I tested on two android devices (Android 7 and Android 4.4) and with the lates ionic version rc5.

  • adesinamark

    Nice article Josh, one question please, How can I get reference to my ion-header from the ngOnInit?

  • Tom van Eijk

    Hi Josh,

    Thanks! But i notice some white pixels and flickering during rendering. Can you please check this screen recording? http://sendvid.com/d17x5roa

    • Sidney Correia

      Can you share your source code?

    • Luukschoen

      I actually changed the this.translateAmt = ev.scrollTop / 2 into this.translateAmt = ev.scrollTop / 6 and it seems to run supersmooth now

  • Sidney Correia

    ionScroll event is not triggered.

    What to look for?

  • To people who are experiencing issues when running on iOS, make sure to install the WKWebView plugin: http://blog.ionic.io/cordova-ios-performance-improvements-drop-in-speed-with-wkwebview/

    When using UIWebView it won’t receive scroll events whilst the list is scrolling from momentum, WKWebView fixes this.

  • hats

    I updated to the latest ionic version 2.3.0 and the update breaks the parallax effect.

    It seems the ionScroll event is not triggered.

    Did anyone experienced this?

  • napcat

    I update to ionic 3… works with 3.0, but not with 3.0.1.

    Can’t figure why…

    Maybe a bug on the ion scroll event?

    • Luukschoen

      For me it works on 3.0.1 . Not using Lazy Loading atm

  • Pavel

    Hi. Try to scroll down, and then very fast scroll to top: you will see a blink of header image… it’s to ugly((
    maybe you have ideas how to solve it?

    • Make sure to install the WKWebView plugin

      • Pavel

        unfortunately, it is appearing on ios & android

      • Pavel

        So… Do you see this weird behavior?

  • Ariel Aleksandrus

    Hi, Josh! This is a great tutorial! I’d like to know, however, how to have multiple parallaxes on the same page with navigation, just like in this site here: http://www.giampierobodino.com/en/high-jewellery/the-splendor-of-details

  • IntelCore

    How can i reuse this directive in multiple pages in ionic 3? it’s not working if i add it to declarations in AppModule and I can’t add to declarations in 2 separate page modules.

    • IntelCore

      I could only get it to work by re-creating the same directive with different name and using it in separate pages.

  • Shashwat Gulyani

    Hello Josh! I am making a PWA, so that cordova plugin won’t solve the problem for me. What should I do?

  • Daniel Marc Ehrhardt

    Hi Josh,
    i think we have a problem with lazy loading. Please check out this forum post
    https://forum.ionicframework.com/t/change-detection-on-directive-crash/97797

    Thank You,
    Daniel

  • Francisco Vieira

    My directive is never fired.
    I am on Ionic v2, anything different?

  • Ankit Kaushik

    Hey Jorge,

    Performed all of these steps on Ionic 3 app but not able to see Parallax effect. Do i need to make any specific change for Ionic 3?

    Cheers.

  • Captain

    this is a great tutorial but I am gettinga runtime error :

    (index):44 GET http://localhost:8100/build/polyfills.js
    core.es5.js:354 Uncaught reflect-metadata shim is required when using class decorators
    DecoratorFactory @ core.es5.js:354
    210 @ cart.ts:4
    __webpack_require__ @ bootstrap edb9d828cc7623fcdee0:54
    209 @ main.js:1004
    __webpack_require__ @ bootstrap edb9d828cc7623fcdee0:54
    283 @ contact.ts:25
    __webpack_require__ @ bootstrap edb9d828cc7623fcdee0:54
    231 @ main.ts:5
    __webpack_require__ @ bootstrap edb9d828cc7623fcdee0:54
    213 @ main.js:1240
    __webpack_require__ @ bootstrap edb9d828cc7623fcdee0:54
    webpackJsonpCallback @ bootstrap edb9d828cc7623fcdee0:25
    (anonymous) @ main.js:1
    (index):51 GET http://localhost:8100/assets/fonts/roboto-regular.woff2
    (index):54 GET http://localhost:8100/assets/fonts/roboto-regular.woff
    (index):1 GET http://localhost:8100/assets/fonts/roboto-regular.ttf