Directivas elemento en AngularJS: lazy-loading images

¡Hola!

Os traigo un tutorial análogo al reciente Directivas atributo en AngularJS:lazy loading images, pero esta vez viendo como hacerlo con una directiva tipo elemento.

El objetivo del tutorial es ver como mejorar la carga de nuestras imágenes para no bloquear nuestra web o mobile app mientras descargamos las imágenes.

La idea es establecer un thumbnail y una imagen de alta resolución que se cargará en paralelo, así como un complemento para centrar y recortar la imagen siguiendo las proporciones de nuestro elemento.

Por otro lado, añadiremos también un atributo para “activar” la imagen en alta resolución cuando nos convenga, lo que podría ser un complemento interesante, por ejemplo, en un carrousel de imágenes con AngularJS.

Creando el escenario

Si has leido el tutorial de directivas atributo, este punto es idéntico.

Lo que haremos es cargar 10 imágenes en alta resolución (cortesía de google), pero sin que la descarga nos bloquee el correcto comportamiento de navegación (por ejemplo, el scroll). Además, nuestra idea es mostrarlas como background-image, dado que así podremos centrar y recortar la imagen al tamaño del elemento de forma sencilla.

Para ello, creamos un archivo index.html con la siguiente estructura:

<html>
<head>
<link href="style.css" rel="stylesheet">
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.26/angular.min.js"></script>
<script src="app.js"></script>
</head>

<body>
<div class="container" ng-app="myApp" ng-controller="main">

<br/>
<div ng-repeat="image in images" style="vertical-align: top;">
---------
{{$index}} ---------<br/>
<!-- aqui meteré mi directiva con la imagen-->
</div>

</body>
</html>

Como podemos ver, utilizamos la directiva ng-repeat para iterar sobre un conjunto de imágenes, mostrando de momento, para cada una de ellas, el índice de la iteración.

Evidentemente, necesitaremos crear un controlador, que será muy sencillo y solo incluirá en nuestro scope:

  • spinnerURL: La URL a la imagen que utilizaremos como thumbnail, mientras no carguemos la imagen completa.
  • images: El array con las URLs de las imágenes de alta resolución. ¿Temática? Gatitos, claro.
  • activeImages: Un array con un booleano por imagen, con el que decidiremos si mostramos la imagen en alta resolución o no.
  • switchActiveValueOnImage: Una función con la que, dada una imagen concreta del array, alternamos su valor (si es false pasa a ser true y viceversa).

Este controlador lo creamos en nuestro archivo de javascript con el módulo de AngularJS, que como hemos visto en index.html, lo llamamos app.js:

angular.module("myApp", [])
.controller("main", function($scope) {

$scope.spinnerURL = "https://38.media.tumblr.com/bec5933eea5043acf6a37bb1394384ab/tumblr_meyfxzwXUc1rgpyeqo1_400.gif";

$scope.images = ["http://critterbabies.com/wp-content/gallery/kittens/803864926_1375572583.jpg",
"http://kpbs.media.clients.ellingtoncms.com/img/news/tease/2014/08/20/Cute_grey_kitten.jpg",
"http://upload.wikimedia.org/wikipedia/commons/a/a5/Red_Kitten_01.jpg",
"http://www.androscogginanimalhospital.com/blog/wp-content/uploads/2013/12/kitten-photo.jpg",
"http://upload.wikimedia.org/wikipedia/commons/d/d3/Little_kitten.jpg",
"http://www.sfgov2.org/ftp/uploadedimages/acc/Adoption_Center/Foster%20Kitten%2012007.jpg",
"http://upload.wikimedia.org/wikipedia/commons/b/bd/Golden_tabby_and_white_kitten_n01.jpg",
"http://fc01.deviantart.net/fs45/f/2009/060/e/e/Kitten_and_Faucet_no__3_by_Mischi3vo.jpg",
"http://miriadna.com/desctopwalls/images/max/Grey-kitten.jpg",
"http://img1.wikia.nocookie.net/__cb20140227161247/creepypasta/images/f/f0/Cats-and-kittens-wallpapers-hdkitten-cat-big-cat-baby-kitten-sleep-2560x1024px-hd-wallpaper--cat-umizfbaa.jpg"
]

$scope.activeImages = [false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
$scope.switchActiveValueOnImage = function(index)
{

$scope.activeImages[index] = !$scope.activeImages[index];
}
})

Podemos comprobar (te recuerdo que una de las formas más sencillas es utilizar el servidor de desarrollo http de python: $ python -m SimpleHTTPServer) como de momento iteramos sobre nuestro array, pero solo pintamos el índice:

iterating over array with ng-repeat

Directiva elemento

Directiva interna de centrado

Aunque queremos utilizar una directiva tipo elemento, hay una directiva atributo que reaprovecharemos de nuestro anterior tutorial, y utilizaremos en el template de nuestra directiva elemento.

Se trata de la directiva myCenterBg, que:

  • centra y escala la imagen en background
  • sin modificar su aspect ratio
  • recorta la imagen para ocupar el tamaño del elemento que la contiene

En este caso, es acertado definirla como atributo, ya que meramente se dedica a decorar el elemento que la utiliza, sin modificar su lógica.

Veamos el código, que podemos añadir a nuestro app.js, a continuación del controlador:

.directive('myCenterBg', function(){
return{
restrict: 'A',
link: function(scope, element, attrs) {
element.css({
'background-size': 'cover',
'background-repeat': 'no-repeat',
'background-position': 'center center',
});
}
}
})

Elemento lazy-image

Ahora sí, crearemos una directiva elemento a la que pasaremos 5 atributos:
* thumb: Url a la imagen que cargaremos inmediatamente como background-image dado que la idea es que sea de baja resolución.
* image: Url a la imagen en alta resolución que cargaremos de forma ajena al DOM.
* active-image: Booleano con el que decidiremos si mostrar la imagen en alta resolución.
* width: Anchura de la imagen, como atributo CSS
* height: Altura de la imagen, como atributo CSS

Esperamos utilizar la directiva del siguiente modo:

<my-lazy-image width="200px" height="200px" 
thumb="url/a/mi/thumbnail.png"
image="url/a/mi/imagenHD.png"
active-image="true/false" />

En nuestra directiva, vamos a utilizar los siguientes parámetros:

  • scope:{} : Un entorno aislado, para definir nuestras propias variables ajenas al scope principal.

  • restrict: ‘E’ : La restricción tipo Elemento.

  • template : “< div my-center-bg>< /div>”: Nuestro template, que será un simple DIV, con la particularidad de que utilizará la directiva anterior, myCenterBg.

  • link: function(scope, element, attrs){…}: La función de linkado, donde definimos el comportamiento de nuestra directiva.

Veamos ahora el contenido de la directiva, que también añadiremos en app.js:

.directive('myLazyImage', function(){
return {
scope: {},
restrict: 'E',
template : "<div my-center-bg></div>",
link: function(scope, element, attrs) {

if(attrs.thumb){
var backgroundImg = attrs.thumb;
}

element.children().css({
'background-image': 'url(' + backgroundImg + ')',
'width': attrs.width,
'height': attrs.height,
});

if(attrs.image){

scope.img = new Image();

scope.img.onload = function(){
if(scope.active){
element.children().css({
'background-image': 'url(' + this.src + ')'
});
}
}
scope.img.src = attrs.image;

if(attrs.activeImage)
{
scope.active = false;
attrs.$observe('activeImage', function(isActive){
scope.active = scope.$eval(isActive);
if(scope.active){
element.children().css({
'background-image': 'url(' + scope.img.src + ')'
});
}
else{
element.children().css({
'background-image': 'url(' + backgroundImg + ')',
});
}

});
}
else
{
scope.active = true;
}

}
}
}
});

Comportamiento del atributo thumb

Podemos ver como en caso de contener dicho atributo, guardamos su valor en una variable. Acto seguido, asignamos las propiedades CSS de anchura, y altura, así como dicha imagen (en caso de existir) como brackground-image.

Esto es lo que se hace en este fragmento:

      if(attrs.thumb){
var backgroundImg = attrs.thumb;
}

element.children().css({
'background-image': 'url(' + backgroundImg + ')',
'width': attrs.width,
'height': attrs.height,
});

DETALLE: Cabe destacar que no trabajamos directamente sobre el element, si no que utilizamos su método children(). Esto se debe a que la directiva elemento crea una estructura anidada con el contenido del template, por lo que el DIV que hemos creado será un elemento hijo de nuestra directiva en la estructura del DOM.

Comportamiento del atributo image

Con respecto a la imagen en alta resolución, lo que hacemos es crear un objeto Image que guardaremos en nuestro scope privado, y asignarle la url de nuestro atributo image. De este modo la imagen se cargará, pero fuera del DOM.
Además, damos contenido al método onload de la imagen, para asegurarnos que después de cargarse (en caso de que hayamos activado ya la imagen con el atributo active-image), se establezca como bacground-image de nuestro elemento.

Esto es lo que se hace en este fragmento:

      if(attrs.image){

scope.img = new Image();

scope.img.onload = function(){
if(scope.active){
element.children().css({
'background-image': 'url(' + this.src + ')'
});
}
}

Comportamiento del atributo active-image

Finalmente comprobamos si estamos acompañando nuestra directiva de otro atributo llamado activeImage. En caso negativo, como no podremos evaluar cuando se quiere la imagen activa y cuando no, ponemos el valor de nuestra variable interna scope.activeImage a true de forma permanente.

No obstante, en caso contrario, lo que hacemos es monitorizar el valor de ese atributo, con la función $observe, y en caso de cambio, evaluaremos el contenido de la misma con $eval (recordemos que el atributo se pasa como un string y queremos un booleano), y en caso asertivo, pondremos nuestra imagen (es previsible que ya se haya cargado) como imagen de background.

DETALLE: A diferencia del ejemplo del otro día con las directivas atributo, aquí estamos añadiendo una nueva funcionalidad: Si vuelves a hacer click en la imagen (poniendo el valor activeImage a false, el thumbnail vuelve a reemplazar a la imagen original.

Esto puede ser de gran utilidad en mobile apps, donde un DOM muy pesado puede arruinarnos las animaciones de una galería tipo slideshow, por ejemplo.

El fragmento de código donde trabajamos con el atributo activeImage es el siguiente:

        if(attrs.activeImage)
{
scope.active = false;
attrs.$observe('activeImage', function(isActive){

scope.active = scope.$eval(isActive);
if(scope.active){
element.children().css({
'background-image': 'url(' + scope.img.src + ')'
});
}
else{
element.children().css({
'background-image': 'url(' + backgroundImg + ')',
});
}

});
}
else
{
scope.active = true;
}

Resultado final de mi directiva elemento

En nuestra app lo que nos interesa es que cuando seleccionemos el thumbnail, se muestre la imagen HD.

Utilizando nuestro index.html de antes, insertaríamos nuestra directiva del siguiente modo:

<html>
<head>
<link href="style.css" rel="stylesheet">
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.26/angular.min.js"></script>
<script src="app.js"></script>
</head>


<body>
<div class="container" ng-app="myApp" ng-controller="main">
<br/>

<div ng-repeat="image in images" style="vertical-align: top;">
---------
{{$index}} ---------<br/>

<!-- AQUI USO LA DIRECTIVA -->
<my-lazy-image width="200px" height="200px" thumb="
{{spinnerURL}}" image="{{image}}" active-image="{{activeImages[$index]}}" ng-click="switchActiveValueOnImage($index)" />

</div>

</div>
</body>
</html>

Puedes ver el resultado final y su código en el siguiente CodePen:

See the Pen Lazy loading images with element directive by Enrique (@-kaik-) on CodePen.