Lazy loading images and managing lists with Ionic VirtualScroll

February 13, 2018, 9:00 am Categories:

Categories

One of the main tasks that a mobile application will be expected to handle is displaying lists of content from local and/or remote data sources (I.e. JSON files, Firebase, Social media feeds etc).

As device resources can be easily taxed it's important that our lists are optimised for viewing and performance; particularly where scrolling large data sets is concerned. If we combine images into such lists we run the very real risk of scroll jank - that great contribution to the user experience where our application starts to become unresponsive and 'jittery' when scrolled.

Thankfully the team at Ionic have developed the VirtualScroll API to help developers avoid such scenarios and we'll be exploring how this is used over the course of this tutorial.

What we'll be developing

As a child of the 80's I loved reading comics so, for the purposes of this tutorial, we'll go through creating a single page application (SPA) that displays a list of 20th Century British comic publications categorised by specific genres.

We'll be using the VirtualScroll API and <ion-img> component to only load and display images for the list WHEN our records (created from a remote JSON file that we'll be retrieving) are inside the browser viewport (and not outside of it) - as demonstrated in the following screen captures:

IPhone screen captures displaying the different stages of a page as the user scrolls down the screen

Why the <ion-img> tag?

One word: performance.

For large lists of data the <ion-img> component offers the following benefits over using the standard HTML <img> tag:

  • Only loads images which are visible in the viewport (avoiding unnecessary network requests for images which aren't visible)
  • Uses web workers for HTTP requests
  • Prevents jank while scrolling
  • Leverages in-memory caching

As the application that we'll develop will be working with a large list of data (just over 400 hundred records) the benefits of using the <ion-img> component will pay dividends where performance and user experience is concerned.

IMPORTANT: Be aware that the <ion-img> component is only designed to be used inside the VirtualScroll component.

Getting started

Now that we know what we're developing let's make a start by opening the system CLI and creating a new project named ionic-comics with the following commands (installing the necessary mobile platforms too):

ionic start ionic-comics blank
cd ./ionic-comics
ionic cordova platform add ios
ionic cordova platform add android

We won't be installing any third party libraries or Ionic Native/Apache Cordova plugins so our next, and final, step in setting up the project is to configure the application root module - ionic-comics/src/app/app.module.ts - with the HttpClientModule import like so:

import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';

import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';

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

We need to import this into the ionic-comics application's root module as we'll be making use of the Angular HttpClient module to retrieve the JSON data source for the application (which we'll explore shortly).

With that minor configuration in place we can now turn our attention to the actual data that we'll be importing into the application to generate our list with.

The data source

Our data source consists of the following comics.json file (for the purposes of space and time the data snippet shown below contains only 1/10th the amount of records that our application will be using - feel free to download the assets for this tutorial here):

{
   "comics": [
      {
         "title" : "Battle",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/battle.png"
      },
      {
         "title" : "Commando",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/commando.png"
      },
      {
         "title" : "Warlord",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/warlord.png"
      },
      {
         "title" : "Victor",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/victor.png"
      },
      {
         "title" : "Action",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/action.png"
      },
      {
         "title" : "Hotspur",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/hotspur.png"
      },
      {
         "title" : "Hornet",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/hornet.png"
      },
      {
         "title" : "Tornado",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/tornado.png"
      },
      {
         "title" : "War Picture library",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/war-picture-library.png"
      },
      {
         "title" : "Bullet",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/bullet.png"
      },
      {
         "title" : "Eagle",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/eagle.png"
      },
      {
         "title" : "Vulcan",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/vulcan.png"
      },
      {
         "title" : "2000AD",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/2000ad.png"
      },
      {
         "title" : "Starblazer",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/starblazer.png"
      },
      {
         "title" : "Metal Hurlant",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/metal-hurlant.png"
      },
      {
         "title" : "Lion",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/lion.png"
      },
      {
         "title" : "TV21",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/tv-21.png"
      },
      {
         "title" : "Judge Dredd Megazine",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/judge-dredd-megazine.png"
      },
      {
         "title" : "TV21",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/tv-21.png"
      },
      {
         "title" : "TV21",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/tv-21.png"
      },
      {
         "title" : "Beano",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/beano.png"
      },
      {
         "title" : "Dandy",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/dandy.png"
      },
      {
         "title" : "Buster",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/buster.png"
      },
      {
         "title" : "Whoopee",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/whoopee.png"
      },
      {
         "title" : "Krazy",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/krazy.png"
      },
      {
         "title" : "Whizzer and Chips",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/whizzer-and-chips.png"
      },
      {
         "title" : "Hoot",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/hoot.png"
      },
      {
         "title" : "Topper",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/topper.png"
      },
      {
         "title" : "Beezer",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/beezer.png"
      },
      {
         "title" : "Sparky",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/sparky.png"
      },
      {
         "title" : "Tiger",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/tiger.png"
      },
      {
         "title" : "Roy of the Rovers",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/roy-of-the-rovers.png"
      },
      {
         "title" : "Scorcher",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/scorcher.png"
      },
      {
         "title" : "Hurricane",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/hurricane.png"
      },
      {
         "title" : "Score ‘n’ Roar",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/score-n-roar.png"
      },
      {
         "title" : "Valiant",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/valiant.png"
      },
      {
         "title" : "Striker",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/striker.png"
      },
      {
         "title" : "Victor",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/victor.png"
      },
      {
         "title" : "Lion",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/lion.png"
      },
      {
         "title" : "Eagle",
         "cover" : "http://REMOTE-URL.SUFFIX/imgs/eagle.png"
      }
   ]
}

This JSON file will need to be hosted on a remote server location that you have access to as we'll be subsequently requesting the file from this location within our Ionic application (which we'll get on to shortly).

Caveat emptor

Before we get stuck into the coding for the application there is one MASSIVE red flag to be aware of.

The VirtualScroll functionality, as currently supplied by Ionic (as of February 2018), is broken.

Yes, you read that correctly - it's broken.

If you were to follow the official documentation for the component and start using that within your project you'd likely experience one or more of the following bugs:

  • Images failing to load in the viewport prior to the page being scrolled
  • Images 'disappearing' from the viewport after appearing
  • Images sometimes loading and sometimes not

Having personally experienced all three of the above in my own projects (as well as many other developers complaining about the same issue on this forum thread as well as this forum thread) I can tell you that it's no isolated incident.

Judging from the age of those threads and the large number of posts they've received this issue, unfortunately, doesn't appear to have gone away.

Bizarrely there doesn't appear to be much feedback from the team at Ionic on the matter either - which isn't good for developer relations (not to mention making sure your product works as expected).

Hopefully they'll fix this issue in Ionic 4 (and it DOES need fixing as this is a critical piece of functionality for performance as well as user experience) but, in the meantime, the following workaround solved the issue for me (thanks to code taken from this particular post):

ngAfterViewInit() : void
{
   if (this._content) 
   {
      this._content.imgsUpdate = () => 
      {
         if (this._content._scroll.initialized && this._content._imgs.length && this._content.isImgsUpdatable()) 
         {
            // Reset cached bounds
            this._content._imgs.forEach((img: Img) => (<any>img)._rect = null);

            // Use global position to calculate if an img is in the viewable area
            updateImgs(this._content._imgs, this._content._cTop * -1, this._content.contentHeight, this._content.directionY, 1400, 400);
         }
      };
   }
}

I'll detail how this is added into the application in the next section but for now be aware that without this workaround the VirtualScroll component will, more than likely, NOT work properly.

Building out the HomePage component

As this is a Single Page Application all of the required logic and templating will take place within the HomePage component - which will be responsible for handling the following tasks:

  • Retrieves the comics.json file from the remote server
  • Parses the returned data and assigns this to a property for use in the component template
  • Implements the VirtualScroll and <ion-img> components to handle the rendering of large record sets and lazy loading images

We'll start with the logic for the component class located at ionic-comics/src/pages/home/home.ts as follows (I've commented this in full to include the VirtualScroll workaround):

import { Component, ViewChild } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { updateImgs } from 'ionic-angular/components/content/content';
import { Img } from 'ionic-angular/components/img/img-interface';
import { Content, NavController } from 'ionic-angular';



export interface Config {
   comics : string
}


@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {
   @ViewChild(Content) _content: Content;



   /**
    * @name items
    * @type {any}
    * @public
    * @description     Defines an object for storing returned comics data
    */
   public items : any;




   /**
    * @name config
    * @type {any}
    * @public
    * @description     Defines an object allowing the interface properties to be accessed
    */
   public config : Config;



   constructor(public navCtrl : NavController,
               private _HTTP  : HttpClient) 
   {
 
   }



   
   /**
    * HACK ALERT! - Resolves issue with <ion-img> tags not displaying images on
    * initial load AND unloading images that have been previously loaded/displayed
    * 
    * Unfortunately this IS needed as the team at Ionic STILL haven't resolved
    * this issue as shown in the following threads: 
    *
    * https://github.com/ionic-team/ionic/issues/9660
    * https://github.com/ionic-team/ionic/issues/11326
    *
    * More details on this hack that fixes the issue can be found at the following 
    * forum thread: https://github.com/ionic-team/ionic/issues/9660#issuecomment-322739124
    *
    * Credited to the following developer: https://github.com/decpio
    *
    * @public
    * @method ngAfterViewInit
    * @return {none}
    */
   ngAfterViewInit() : void
   {
      if (this._content) 
      {
         this._content.imgsUpdate = () => 
         {
            if (this._content._scroll.initialized && this._content._imgs.length && this._content.isImgsUpdatable()) 
            {
               // Reset cached bounds
               this._content._imgs.forEach((img: Img) => (<any>img)._rect = null);

               // Use global position to calculate if an img is in the viewable area
               updateImgs(this._content._imgs, this._content._cTop * -1, this._content.contentHeight, this._content.directionY, 1400, 400);
            }
         };
      }
   }
   



   /**
    * Retrieve the comics.json file (supplying the data type, via
    * the config property of the interface object, to 'instruct' Angular
    * on the 'shape' of the object returned in the observable and how to
    * parse that)
    *
    * @public
    * @method ionViewDidLoad
    * @return {none}
    */
   ionViewDidLoad() : void
   {
      this._HTTP
      .get<Config>('http://REMOTE-URL.SUFFIX/comics.json')
      .subscribe((data : any) =>
      {
         this.items = data.comics
      });
   }




   /**
    * Render headings for each comic genre at every 10 record interval
    * NOTE: The headings are deliberately replicated to demonstrate the
    * Virtual Scroll component API working with large data sets :)
    *
    * @public
    * @method renderDividers
    * @param record   	   {object} 		The current record
    * @param recordIndex   {object} 		The index of the current record
    * @param records 	   {object} 		The entire array of records
    * @return {Object}
    */
   renderDividers(record : any, recordIndex : number, records : any)  : any
   {
      let heading : any       	= [ 'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport',
                                    'War/Adventure', 'Science Fiction/Fantasy', 'Humour', 'Sport'],
          num     : number 		= 0;
      

      // IF this is every tenth record we want to 
      // inject the correct heading from the above
      // array into the list to act as a divider 
      // between different comic genres
      if (recordIndex % 10 === 0) 
      {
         num++;
         return heading[num * recordIndex/10];
      }
      return null;
   }



}

As a relatively thin class the component logic should be pretty simple to understand - particularly with the addition of the above comments.

In short the comics.json file is retrieved using the get method of Angular's HttpClient class (with a little help from a custom interface so that the 'shape' of the returned data from the Observable is able to be understood by Angular), assigned to an items property for use in the component template and a renderDividers method is defined which inserts a custom header (whose values are programatically assigned from an array) into the list after every 10 records.

This custom header will be used to group records by genre and allow the user to categorise the comics more easily.

The component markup structure

Within the ionic-comics/src/pages/home/home.html template we're going to start implementing the VirtualScroll component as follows:

<ion-list 
   [virtualScroll]="items" 
   [headerFn]="renderDividers" 
   approxItemHeight="59px">

Here we assign the items array (that contains the returned JSON data - and this MUST be supplied as an array) to the VirtualScroll property which we've attached to an <ion-list> component.

The renderDividers method is assigned to a headerFn property which is provided by the VirtualScroll API to allow section headers to be generated for this list.

When the VirtualScroll component receives data it generates templates for each cell that is to be rendered within the viewport.

We then assign an attribute of approxItemHeight to the <ion-list> component which helps the VirtualScroll understand what the height should be for each template that it needs to render in the viewport (the default height for a list item is 40px).

We follow this by defining the markup for generating the list section headers:

<ion-item-divider 
   *virtualHeader="let header">
   {{ header }}
</ion-item-divider>

Finally we add the following <ion-item> and <ion-img> components to render the list data:

<ion-item 
   *virtualItem="let item">
   <ion-avatar item-start>
      <ion-img 
         [src]="item.cover" 
         width="40" 
         height="40" 
         alt="Cover for {{ item.title }} comic publication" 
         cache=true></ion-img>
   </ion-avatar>
   <h2>{{ item.title }}</h2>
</ion-item>

The <ion-img> component makes use of various attributes; most notably those of width and height as these help the VirtualScroll component to 'size up' the area being rendered and subsequently calculate that in relation to the viewable area (as explained here).

In full then the markup for the ionic-comics/src/pages/home/home.html template appears as follows:

<ion-header>
  <ion-navbar>
    <ion-title>
      Ionic Comics
    </ion-title>
  </ion-navbar>
</ion-header>

<ion-content padding>
  

   <ion-list 
      [virtualScroll]="items" 
      [headerFn]="renderDividers" 
      approxItemHeight="59px">
   

     <ion-item-divider 
        *virtualHeader="let header">
        {{ header }}
     </ion-item-divider>


     <ion-item 
        *virtualItem="let item">
        <ion-avatar item-start>
           <ion-img 
              [src]="item.cover" 
              width="40" 
              height="40" 
              alt="Cover for {{ item.title }} comic publication" 
              cache=true></ion-img>
        </ion-avatar>
        <h2>{{ item.title }}</h2>
     </ion-item>
   
   </ion-list>

</ion-content>

With development now completed for the ionic-comics application we can move onto testing the application .

Building, running, testing

With this final tweak in place we've now concluded the ionic application development for our tutorial so this would be a good time to actually build the application, deploy this to a handheld device and test that it works!

To begin with, IF developing for iOS, double-click the ionic-comics Xcode project - located at ionic-comics/platforms/ios/ionic-comics.xcodeproj - to open this within Xcode and ensure that a Team is selected in the Project Targets > Signing section (highlighted in red - although this is a completely different project screen capture the same process holds true!):

Xcode team selection for an iOS application

Now open your system CLI, navigate to the root of the ionic-comics/ directory and run the following commands (substitute android for ios IF developing for that platform instead) to build the application and, once completed, run on a handheld device connected to your computer:

ionic cordova build ios --prod
ionic cordova run ios

All things being well the application should build without issue, install to your device and run without problem - as demonstrated in the following screen captures:

IPhone screen captures displaying the different stages of a page as the user scrolls down the screen

Thanks to a little help from the previously mentioned workaround our application can make use of the VirtualScroll component to scroll 400+ records and the <ion-img> component to lazy load images for the viewable area.

In summary

The VirtualScroll and <ion-img> components allow the scrolling of huge lists of data and lazy loading images to be much more performance optimised and makes the user experience far more enjoyable as a result.

This is why, as a developer, it's important to make full use of the framework's components rather than trying to roll your own solution when it comes to dealing with matters such as scrolling performance. Why go to all the time and effort of trying to reinvent the wheel when the problem has already been fixed for you?

Well....that would be the case were it not for our required workaround.

That aside (and hopefully Ionic will fix this critical bug soon) the VirtualScroll and <ion-img> components are designed to be used together so DO make full use of their functionality if you need to load hundreds of list items/images.

If you've enjoyed what you've read and/or found this helpful please feel free to share your comments, thoughts and suggestions in the comments area below.

I explore further projects for the Ionic framework within my e-book featured below and if you're interested in learning more about further articles and e-books please sign up to my FREE mailing list.

Tags

Categories

Post a comment

All comments are welcome and the rules are simple - be nice and do NOT engage in trolling, spamming, abusiveness or illegal behaviour. If you fail to observe these rules you will be permanently banned from being able to comment.

Top