Directivas atributo en AngularJS: lazy-loading images

Después de un artículo introductorio sobre directivas en AngularJS como Profundizando en Angular.js: Directivas personalizadas y servicios y un tutorial más complejo como Carrousel con AngularJS y HammerJS:Profundizando en las directivas, ya deberíamos tener una cierta soltura con las directivas de AngularJS, así que que ¡traigo un nuevo tutorial!

Hoy vamos a 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.

Con fines didácticos, esto lo vamos a conseguir con directivas atributo (para este caso lo más adecuado sería utilizar directivas elemento, sigue este enlace para ver el lazy-loading de imágenes con directivas elemento).

Creando el escenario

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

Directivas atributo

Thumbnail image

Con esta directiva, que llamaremos myThumb, lo que buscamos es pasar la url a una imagen que cargaremos como background-image. En este caso cargamos la imagen immediatamente, por que la idea es que sea de baja resolución.

Esperamos utilizar la directiva del siguiente modo:

<div my-thumb="url/a/mi/imagen.png"></div>

Veamos el contenido de la directiva, que podemos añadir en app.js a continuación del controlador:

.directive('myThumb', function() {
return {
restrict: 'A',
link: function(scope, element, attrs) {

if(attrs.myThumb)
{
element.css({
'background-image': 'url(' + attrs.myThumb + ')',
});
}
}
}
})

Podemos observar como restringimos el tipo de directiva a atributo, mediante restrict: ‘A’.

A continuación comprobamos que realmente el atributo myThumb (recordad que AngularJS pasa el formato camelCase en JS a formato con-guiones en HTML), contenga algo (esperamos una URL), y a continuación, cogemos el valor asignado al atributo my-thumb, y se lo ponemos como brackground image, con el siguiente código:

 element.css({
'background-image': 'url(' + attrs.myThumb + ')',
});

Centrado de background

Ahora vamos a crear una directiva muy sencilla, que símplemente se encargará de centrar y escalar la imagen de background de forma proporcionada, recortando lo necesario para adaptarse a las proporciones del elemento. La llamaremos myCenterBg, y la podemos crear del siguiente modo:

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

Como podemos ver, lo único que hace es aplicar una serie de comportamientos al CSS. En este caso, no asignamos ningún valor al atributo. Para centrar la imagen en BG de thumbnail, hariamos lo siguiente:

<div my-thumb="url/a/mi/imagen.png" my-center-bg></div>

Imagen principal con lazy-loading

Ahora vamos ya a ver como cargar nuestra imagen de alta resolución con técnicas de lazy-loading y como background-image, para aprovechar la directiva de centrado anterior.

.directive('myImage', function() {
return {
scope: {},
restrict: 'A',
link: function(scope, element, attrs) {


if(attrs.myImage){

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

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

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

});
}

}

}
}
})

Observar en primer lugar el uso de scope:{}. Con esto lo que hacemos es crear un scope privado para nuestra directiva. De este modo, la variable scope.img que usaremos en breve podrá ser diferente para cada elemento que use esta directiva.

Con el primer bloque, lo que hacemos es crear una imagen fuera del DOM, que guardamos como scope.img, y asignarle la URL de nuestra imagen, pasada mediante el atributo myImage. Además creamos una función onload para que una vez cargada la imagen en memoria y solo si nuestra variable interna scope.activeImage está a true, se le asigne como imagen de background al elemento.
Esto es exactamente lo que hace este fragmento:

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

En el siguiente bloque, 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 (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.

Uso conjunto

Podríamos utilizar las tres directivas anteriores, del siguiente modo:

<div class="my-img" my-thumb="thumb/url" my-image="img/url" my-center-bg active-image="true/false">
</div>

con una hoja de estilos style.css:

.my-img{
width: 200px;
height: 200px;
}

Resultado final con directivas atributo

En nuestra app lo que nos interesa es que cuando seleccionemos el thumbnail, se muestre la imagen HD (pensemos por ejemplo en el caso de una galería, donde no tenemos por qué mostrar todas las imágenes en alta resolución de golpe, si no que podemos mostrar solo sus miniaturas, mientras cargamos las imágenes HD de fondo, y en el momento en que nos desplazamos hasta la imagen en cuestión, reemplazar su thumbnail por la imagen en alta resolución.

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 -->
<div class="my-img" my-thumb="
{{spinnerURL}}" my-image="{{image}}" my-center-bg
active-image="
{{activeImages[$index]}}" ng-click="switchActiveValueOnImage($index)" >
</div>

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

Puedes ver el resultado final en el siguiente CodePen:

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