Cargar listas muy largas en el DOM siempre ha penalizado el rendimiento de una web (especialmente en mobile), provocando «tirones» al hacer scroll. Ionic soluciona este problema con su directiva Virtual Scroll. Hoy te enseño a aprovechar todo su potencial a través de un ejemplo de código.

¿Que es el virtual scroll?

El scroll virtual permite mostrar una lista virtual (y potencialmente infinita) de elementos.

Este virtual scroll recibe un array de datos que le permitirán crear los templates de elementos (celdas), que pueden incluir items, header y footers.

A diferencia de un scroll convencional, éste no renderiza todos los elementos de la lista, sino solo un número suficiente de elementos para llenar la vista de scroll y tener un cierto margen. Estos elementos se reciclan al hacer scroll.

De este modo se reduce drásticamente el número de nodos añadidos al DOM para esa lista. Además esta cantidad se mantiene estable, y no aumenta al hacer scroll, como muestra el siguiente vídeo.

El resultado es evidente: un DOM más ligero, más fluidez. De hecho, esta estrategia de reutilización de celdas para listas «infinitas» ya se usa habitualmente en apps iOS y Android.

Uso básico

Directiva [virtualScroll]

[virtualScroll] es una directiva que tienes que añadir al componente que contiene la lista (por ejemplo, una ion-list, pero puede ser cualquier elemento, como un simple div).

A esta directiva, le pasas el array completo de objetos que quieres mostrar en tu lista.

Aquí tienes un ejemplo:

myPage.html

<ion-content>
    <ion-list [virtualScroll]="items">
        <!--display items, we'll talk later about this-->
    </ion-list>
<ion-content>

myPage.ts

//...some stuff
export class myPage {

  public items:Array<any> = new Array();

  constructor(public navCtrl: NavController) {
    for(let i=0; i<10000; i++){
      this.items.push(`item ${i}`);
    }
  }
}

Directiva estructural *virtualItem

Para que el virtualScroll de Ionic funcione, tienes que utilizar en el template que repites, la directiva estructural *virtualItem. A esta directiva le pasas una variable con la que identificas de forma única cada objeto a renderizar.

Siguiendo con el ejemplo anterior:

myPage.html

<ion-content>

    <ion-list [virtualScroll]="items">
      <ion-item *virtualItem="let item">
        {{ item }}
      </ion-item>
    </ion-list>

</ion-content>

Separadores de sección (headers y footers).

De forma opcional, la directiva virtualScroll te permite añadir headers y footers en medio de la lista.

Para añadir un separador necesitas:

  • La función headerFn o footerFn: Le tienes que pasar al scroll una función que decide si se muestra o no el separador. Esta función devuelve null si no hay que mostrarlo. Devuelve los datos a usar en caso contrario.
  • El componente ion-item-divider, que recibe la directiva estructural *virtualHeader o *virtualFooter. El funcionamiento es muy similar al de *virtualItem

¿Quieres un ejemplo?

Lo imaginaba… 🙂

myPage.html

<ion-content>
    <ion-list [virtualScroll]="items" [headerFn]="myHeaderFn">

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

      <ion-item *virtualItem="let item">
        {{ item }}
      </ion-item>

    </ion-list>
</ion-content>

myPage.ts

//...some stuff
export class myPage {

  public items:Array<any> = new Array();

  constructor(public navCtrl: NavController) {
    //...some stuff...
  }

  myHeaderFn(record, recordIndex, records) {
    if (recordIndex === 0) {
      return 'header at the beginning';
    }
    return null;
  }
}

Como usar headerFn / footerFn

Como acabas de ver, la función headerFn ( y lo mismo ocurre con footerFn), recibe 3 parámetros:

  • record: El item en un momento concreto de la iteración.
  • recordIndex: La posición de dicho item en la lista de elementos.
  • records: La lista completa de objetos que quieres iterar.

A partir de aquí, tienes toda la información necesaria para decidir si incluyes o no el separador (para no incluirlo, devuelve null).

El objeto que devuelves a cada iteración, es lo que recibe la directiva estructural *virtualHeader o *virtualFooter.

Imágenes en el virtualScroll

La carga de imagenes a través del elemento <img> se realiza de forma asíncrona, pero no puedes decidir en qué momento.

Por eso, Ionic ha creado el componente <ion-image>, que se integra a la perfección con el virtualScroll de Ionic y sabe cuando tiene que descargar o no una imagen aunque estés haciendo scroll rápidamente.

NOTA: Actualmente hay un bug conocido con el componente <ion-image> que impide su correcto uso. Hasta que lo resuelvan, te recomiendo usar de momento el elemento <img> de toda la vida.

Aquí tienes un ejemplo de como se usa <ion-image>:

<ion-content>
    <ion-list [virtualScroll]="items">

      <ion-item *virtualItem="let item">
        <ion-avatar item-left>
          <ion-img [src]="item.imgUrl"></ion-img>
        </ion-avatar>
        {{ item.username }}
      </ion-item>

    </ion-list>
<ion-content>

Alto y ancho aproximados

La directiva virtualScroll toma como base una altura de item de 40px en el momento de construir los elementos del DOM (cuando aún no conoce el tamaño real de los elementos).

Por eso, si tus elementos no van a tener este tamaño, es importante especificarlo (no hace falta que sea exacto) con la propiedad approxItemHeight, para evitar que se construyan nodos de más o de menos en el DOM.

Esta propiedad siempre debe especificarse en píxeles, como ves en el siguiente ejemplo:

  <ion-list [virtualScroll]="items" approxItemHeight="100px">

    <ion-item *virtualItem="let item">
      {{item}}
    </ion-item>

  </ion-list>

Lo mismo es válido también al ancho de los elementos. Ionic supone por defecto elementos que ocupen el 100%, pero si no es el caso (imagina que muestras 3 elementos por fila), debes especificarlo con la propiedad approxItemWidth. En este caso puedes usar px o %.

Capacidad del buffer

El scroll virtual genera un número de nodos mayor que los necesarios para cubrir la vista, de modo que cuando hagas scroll no te de la sensación de que se están cargando elementos vacíos.

Para determinar el tamaño de este buffer tienes la propiedad bufferRatio, que es un multiplicador sobre el número de elementos para cubrir la vista.

Es decir, si para cubrir la vista necesitas 5 elementos y el bufferRatio es de 3, el virtual scroll creará 15 elementos.

Si no lo especificas tú, el bufferRatio por defecto es 3.

Componentes custom

Una recomendación importante de Ionic, si lo que quieres es utilizar tu propio componente dentro del virtual scroll, es encapsularlo dentro de un <div> de los de toda la vida, para garantizar que la directiva es capaz de determinar correctamente el tamaño de cada elemento.

Lo harías así:

<ion-list [virtualScroll]="items">

  <div *virtualItem="let item">
    <my-custom-item [item]="item">
       {{ item }}
    </my-custom-item>
  </div>

</ion-list>

Ejemplo completo

Para acabar de consolidar estos conceptos, te voy a poner un ejemplo completo, tipo agenda de usuarios.

Tengo un servicio Agenda con un único método getContacts(), que genera 500 usuarios random, con imagen y todo y los devuelve ordenados alfabéticamente en un array de objetos Contact.

Aquí tienes la interfaz Contact:

export interface Contact{
    name:string,
    phone:number,
    imgUrl:string  
}

Pues bien, voy a crear una vista de «agenda» para mostrar todos los contactos, como ves en la foto.

ion-list with virtual scroll

Usaré VirtualScroll para conseguir un scroll suave, y como puedes intuir de la imagen, incluiré una headerFn para separar los contactos alfabéticamente según su inicial.

Virtual scroll items

Dejo lo de la headerFn para el final, y me centro primero en los items.

La parte Typescript, en este sentido, es muy simple:

contacts.ts

import { Component, OnInit } from '@angular/core';
import { Agenda, Contact } from "../../providers/agenda";


@Component({
  selector: 'page-contacts',
  templateUrl: 'contacts.html'
})
export class ContactsPage implements OnInit {

  public items:Array<Contact> = new Array<Contact>();

  constructor(public agenda: Agenda) { }

  ngOnInit(){
    this.items = this.agenda.getContacts();
  }
}

Como ves, lo único que necesito es un array de items de tipo Contact, que obtengo durante la inicialización del componente, con ngOnInit.

A nivel de template, solo tengo que crear una barra de navegación que diga «Contacts»:

contacts.html

<ion-header>
  <ion-navbar>
    <ion-title>
      Contacts
    </ion-title>
  </ion-navbar>
</ion-header>

Y debajo, un ion-content para tener scroll con my ion-list dentro:

contacts.html

<!-- ... my header...-->

<ion-content padding>

  <ion-list [virtualScroll]="items" 
  approxItemHeight="100px" approxHeaderHeight="80px">

    <ion-item *virtualItem="let item">
        <!-- ion-img has a bug at this moment -->
        <!--<ion-img style="width:100px; height:100px" [src]="item.imgUrl"></ion-img>-->
        <img src="{{item.imgUrl}}" style="width:100px; height:100px">
        <span>
          <h1>{{item.name}}</h1>
          <p>{{item.phone}}</p>
        </span>
    </ion-item>

  </ion-list>

</ion-content>

Fíjate cómo paso los itemsa [VirtualScroll], y cómo cojo luego cada item en particular con la directiva *virtualItem.

Eso me permite acceder luego a la imagen, el nombre y el teléfono del item para mostrarlo en la celda.

A nivel de estilo le añado cuatro cosas para que quede bien maquetado:

contacts.scss

page-contacts {
    ion-item{
        height: 100px;

        span{
            display: inline-block;
            vertical-align: bottom;
            height: 100px;
        }

        h1{
            margin-top: 10px !important;
            margin-bottom: 5px !important;
        }
    }

    //We'll use this in a moment
    ion-item-divider{
        height: 80px;
        align-items: flex-end;
    }
}

Con esto ya tengo la lista de usuarios. ¿Quieres ver que tal va el scroll?

Si hay que ponerle alguna pega, es que si haces mucho scroll, al acabar, las imágenes tardan un poco en cargar (normal) y mientras tanto ves la imagen anterior que tenía esa celda reaprovechada. Por eso ves que algunas imágenes cambian de repente.

Esto es una de las cosas que debería solucionar el componente ion-image, pero como te decía antes, de momento no funciona del todo bien, así que te recomiendo seguir con img a secas.

Header functions

Me falta añadir un separador cada vez que en la agenda se cambia de inicial.

Para eso, lo primero es crear una función que reciba los parámetros que necesita headerFn, y que detecte ese cambio de inicial:

contacts.ts

  checkInitialChange(item, itemIndex, items){
    if(itemIndex == 0)
      return item.name[0];

    if(item.name[0] != items[itemIndex-1].name[0])
      return item.name[0];

    return null;
  }

Fíjate lo que hago: Si se llama la función con el primer elemento de todos, o se detecta un cambio en la primera letra del nombre con respecto al elemento anterior, devuelvo esa primera letra. En caso contrario, devuelvo null para que no se añada ningún header.

Solo me falta tocar la vista para añadir esta función a la lista con virtual scroll, e incluir también el separador con el resultado de la función (la inicial):

contacts.html

<!-- ...some stuff... -->

  <ion-list [virtualScroll]="items" [headerFn]="checkInitialChange" 
  approxItemHeight="100px" approxHeaderHeight="80px">

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

    <!-- ... ion-item stuff and so on ...-->

Con estos pequeños cambios, conseguirás añadir los separadores que veías en la primera imagen.

ion-list with virtual scroll and dividers

Conclusiones

La directiva VirtualScroll de Ionic 3, te ofrece una simplicidad brutal a la hora de trabajar con listas infinitas: Soluciona por ti los problemas de rendimiento que encontrarías en este escenario, y en lugar de perder tiempo con eso, lo puedes dedicar a otras cosas más importantes de tu app.

Para mi es uno de los elementos más interesantes de Ionic 3, y espero que solucionen pronto el bug con ion-image para poderle sacar el máximo rendimiento cuando trabajas con imágenes.

Si quieres el código del ejemplo, lo puedes descargar aquí: https://github.com/kaikcreator/ionicVirtualScroll

¿Te ha gustado este artículo? ¡Déjame un comentario debajo y compártelo en las redes! ¡¡GRACIAS!!