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
ofooterFn
: Le tienes que pasar al scroll una función que decide si se muestra o no el separador. Esta función devuelvenull
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.
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 items
a [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.
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!!