Animaciones

14

Porcentaje completado

En este capítulo:

  • Veremos lo que pasa entre bastidores cuando Meteor intercambia dos elementos del DOM.
  • Aprenderemos a animar la reordenación de posts.
  • Aprenderemos a animar la inserción y borrado de posts
  • Aprenderemos a animar la transición entre dos páginas.
  • Aunque contamos un sistema de votación en tiempo real, no tenemos una gran experiencia de usuario viendo la forma en la que los posts se mueven en la página principal. Usaremos animaciones para suavizar este problema.

    Introduciendo a los _uihooks

    Los _uihooks son una característica de Blaze relativamente nueva y poco documentada. Como su propio nombre indica, nos da acceso a acciones que podemos ejecutar cuando se insertan, eliminan o animan elementos.

    La lista completa de acciones es esta:

    • insertElement: se llama cuando se inserta un elemento.
    • moveElement: se llama cuando un elemento cambia su posición.
    • removeElement: se llama cuando se elimina un elemento.

    Una vez definidas, estas acciones reemplazarán el comportamiento que Meteor tiene por defecto. En otras palabras, en vez de insertar, mover o eliminar elementos, Meteor usará el comportamiento que hayamos definido – ¡y será cosa nuestra que ese comportamiento sea correcto!

    Meteor y el DOM

    Antes de poder empezar con la parte divertida (hacer que se muevan las cosas), tenemos que entender cómo interactúa Meteor con el DOM (Document Object Model - la colección de elementos HTML que componen el contenido de una página).

    Lo más importante que hay que tener en cuenta es que los elementos del DOM realmente no se pueden “mover”. Sólo se pueden añadir y eliminar (esto es una limitación del propio DOM, no de Meteor). Así que para crear la ilusión de que los elementos A y B se intercambian, Meteor tendrá que eliminar B e insertar una nueva copia (B’) antes del elemento A.

    Esto hace de la animación algo complicado ya que, no podemos simplemente mover B a una nueva posición, porque B habrá desaparecido tan pronto como Meteor redibuje de nuevo la página (que, como sabemos, sucede instantáneamente gracias a la reactividad). Pero no te preocupes, encontraremos la manera de hacerlo.

    El corredor ruso

    Pero, primero, una historia.

    En 1980, en pleno apogeo de la guerra fría, los Juegos Olímpicos se celebraban en Moscú, y los soviéticos estaban decididos a ganar la carrera de 100 metros a cualquier precio. Así que un grupo de brillantes científicos soviéticos equiparon a uno de sus atletas con un teletransportador, y en cuanto sonó el disparo de salida, el corredor fue trasladado de inmediato a la línea de meta.

    Afortunadamente, los jueces de la carrera se dieron cuenta de la infracción inmediatamente, y el atleta no tuvo más remedio que teletransportarse de nuevo a su casilla de salida, antes de permitirle participar de nuevo corriendo como los demás.

    Mis fuentes históricas no son muy fiables, por lo que debes tomar esa historia como cogida con pinzas. Pero trataremos de mantener en mente la analogía del “corredor soviético con teletransportador” a medida que avancemos en este capítulo.

    Analizando el problema detenidamente

    Cuando Meteor recibe una actualización y modifica reactivamente el DOM, nuestro post se teletransporta inmediatamente a su posición final, al igual que el corredor soviético. Pero como en los Juegos Olímpicos, en nuestra aplicación, podemos tener alrededor cosas que no se teletransportan. Así que tendremos que teletransportarlo a la “casilla de salida” y hacerlo “correr” (en otras palabras, animarlo) de nuevo hasta la línea de meta.

    Para intercambiar los elementos A y B (situados en las posiciones p1 y p2, respectivamente), tendremos que seguir los siguientes pasos:

    1. Borrar B
    2. Crear B’ antes de A en el DOM
    3. Teletransportar B’ a p2
    4. Teletransportar A a p1
    5. Amimar A hasta p2
    6. Animar B’ hasta p1

    El siguiente diagrama explica estos pasos con más detalle:

    Intercambiando dos posts
    Intercambiando dos posts

    De nuevo, en los pasos 3 y 4 no estamos animando A y B’ hasta sus posiciones sino que las “teletransportamos” allí al instante. Dado que el cambio es instantáneo, parecerá que B no se ha borrado, pero ya tenemos posicionados correctamente los elementos para que puedan ser animados hasta su nueva posición.

    Afortunadamente, Meteor se ocupa de los pasos 1 y 2 y re-implementarlos será una tarea fácil. En los pasos 5 y 6, lo único que hacemos es mover los elementos al lugar adecuado. Así que, sólo tenemos que preocuparnos de los pasos 3 y 4, enviar los elementos al punto de arranque de la animación.

    Posicionamiento CSS

    Para animar los posts que se están reordenando por la página, vamos a tener que meternos en territorio CSS. Sería recomendable una rápida revisión del posicionamiento con CSS.

    Los elementos de una página utilizan posicionamiento estático por defecto. Los elementos posicionados de forma estática están fijos y sus coordenadas no se pueden cambiar o animar.

    Por otra parte, el posicionamiento relativo, implica que el elemento está fijado a la página, pero se puede mover con relación a su posición original.

    El posicionamiento absoluto va un paso más allá y permite dar coordenadas x/y a un elemento en relación al documento o al primer elemento “padre” posicionado de forma absoluta o relativa.

    Nosotros vamos a usar posicionamiento relativo en nuestras animaciones. Ya disponemos del CSS necesario en client/stylesheets/style.css, pero si necesitas añadirlo, este es el código para la hoja de estilo:

    .post{
      position:relative;
    }
    .post.animate{
      transition:all 300ms 0ms ease-in;
    }
    
    client/stylesheets/style.css

    Ten en cuenta que sólo animamos los posts con la clase CSS .animate. De esta forma, podemos añadir y quitar esa clase para controlar cuándo deben producirse o no las animaciones.

    Esto facilita muchos los pasos 5 y 6: todo lo que necesitamos hacer es configurar la parte top a 0px (su valor predeterminado) y nuestros posts se deslizarán de nuevo a su posición “normal”.

    Esto significa que nuestro único problema es averiguar desde dónde animar los posts (pasos 3 y 4) con respecto a su nueva posición. En otras palabras, en qué posición hay que ponerlo. Pero, esto no es tan difícil: el desplazamiento correcto (offset) es la posición anterior restada a la nueva.

    implementando los _uihooks`

    Ahora que entendemos los diferentes factores que entran en juego en la animación de una lista de elementos, estamos listos para empezar a aplicar la animación. Lo primero que necesitaremos para envolver nuestra lista de posts en un nuevo contenedor .wrapper:

    <template name="postsList">
      <div class="posts page">
        <div class="wrapper">
          {{#each posts}}
            {{> postItem}}
          {{/each}}
        </div>
    
        {{#if nextPath}}
          <a class="load-more" href="{{nextPath}}">Load more</a>
        {{else}}
          {{#unless ready}}
            {{> spinner}}
          {{/unless}}
        {{/if}}
      </div>
    </template>
    
    client/templates/posts/posts_list.html

    Antes de continuar, vamos a revisar cuál es el comportamiento actual sin animaciones:

    La lista de posts no-animada.
    La lista de posts no-animada.

    Vamos a por los _uihooks. Dentro del callback onRendered de la plantilla, seleccionamos el div .wrapper , y definimos la acción moveElement.

    Template.postsList.onRendered(function () {
      this.find('.wrapper')._uihooks = {
        moveElement: function (node, next) {
          // do nothing for now
        }
      }
    });
    
    client/templates/posts/posts_list.js

    Cada vez que cambie la posición de un elemento, en vez de obtener el comportamiento predeterminado de Blaze, Meteor llamará a la función moveElement. Y, dado que la función está vacía, no va a pasar nada.

    Adelante, probemos: abre la vista de los “Mejores“ posts y vota unos cuantos: el orden no cambiará hasta que no fuerces un re-render (ya sea volviendo a cargar la página o moviéndote entre distintas rutas).

    Un callback moveElement vacío: no ocurre nada
    Un callback moveElement vacío: no ocurre nada

    Hemos comprobado que los _uihooks funcionan. ¡Ahora vamos a hacer que animen los posts!

    Animando los posts a través del reordenamiento

    La acción moveElement toma dos argumentos: node y next.

    • node es el elemento que se está moviendo a una nueva posición en el DOM.
    • next es el elemento que hay justo después de la nueva posición a la que estamos moviendo node.

    Sabiendo esto, podemos definir el proceso de animación (si necesitas refrescar la memoria, no dudes en volver al ejemplo del “Corredor Ruso”). Cuando detectamos un nuevo cambio en la posición de un elemento, tendremos que hacer lo siguiente:

    1. Insertar node antes de next (en otras palabras, establecer el comportamieento por defecto, como si no hubiéramos definido la acción moveElement).
    2. Mover node a su posición original.
    3. Moveremos todos los elementos que hay entre node y next para hacer sitio a node.
    4. Animaremos todos los elementos hasta su posición original.

    Para hacer todo esto usaremos la magia de jQuery, de lejos, la mejor librería de manipulación del DOM que existe. jQuery está fuera del alcance de este libro, pero vamos a ver rápidamente los métodos que vamos a usar:

    • Con $() convertimos cualquier elemento del DOM en un objeto jQuery.
    • offset() recupera la posición de un elemento en relación al documento, y devuelve un objeto que contiene las propiedades top y left.
    • Con outerHeight() obtenemos la altura “exterior” (incluyendo el padding y, opcionalmente, el margin) de un elemento.
    • Con nextUntil(selector) obtenemos todos los elementos que hay después del elemento seleccionado con el selector, excepto éste último.
    • Con insertBefore(selector) insertamos un elemento antes del que seleccionamos con el selector.
    • Con removeClass(class) eliminamos la clase CSS class, si está presente en el elemento.
    • Con css(propertyName, propertyValue) establecemos el valor propertyValue para la propiedad propertyName.
    • Con height() obtenemos la altura de un elemento.
    • Con addClass(class) añadimos la clase class a un elemento.
    Template.postsList.onRendered(function () {
      this.find('.wrapper')._uihooks = {
        moveElement: function (node, next) {
          var $node = $(node), $next = $(next);
          var oldTop = $node.offset().top;
          var height = $node.outerHeight(true);
    
          // find all the elements between next and node
          var $inBetween = $next.nextUntil(node);
          if ($inBetween.length === 0)
            $inBetween = $node.nextUntil(next);
    
          // now put node in place
          $node.insertBefore(next);
    
          // measure new top
          var newTop = $node.offset().top;
    
          // move node *back* to where it was before
          $node
            .removeClass('animate')
            .css('top', oldTop - newTop);
    
          // push every other element down (or up) to put them back
          $inBetween
            .removeClass('animate')
            .css('top', oldTop < newTop ? height : -1 * height)
    
    
          // force a redraw
          $node.offset();
    
          // reset everything to 0, animated
          $node.addClass('animate').css('top', 0);
          $inBetween.addClass('animate').css('top', 0);
        }
      }
    });
    
    client/templates/posts/posts_list.js

    Algunas notas:

    • Calculamos la altura de $node para saber cuánto debemos mover los elementos $inBetween. Y usamos outerHeight(true) para incluir margen y padding en el cálculo.
    • No sabemos si next va antes o después de node así que comprobamos las dos configuraciones cuando definimos $inBetween.
    • Para cambiar los elementos de “teletransportados” a “animados”, simplemente añadimos o quitamos la clase animate (la animación definida en el código CSS de la aplicación).
    • Dado que usamos posicionamiento relativo, siempre podemos poner a 0 la propiedad top del elemento para devolverlo a la posición dónde se supone que tiene que ir.

    Forzando el redibujado

    Te estarás preguntando para qué es la línea $node.offset(). ¿Para qué obtenemos la posición de $node si no vamos a hacer nada con ella?

    Míralo así: si le dices a un robot muy inteligente que se mueva al norte 5 kilómetros, y luego al sur otros 5, probablemente sabrá deducir que va a terminar en el mismo sitio, y que puede ahorrar energía y hacer bien el trabajo sin moverse.

    Así que si quieres que el robot ande 10 kilómetros, le diremos que mida sus coordenadas a los 5 kilómetros, antes de que de la vuelta.

    El navegador funciona de una manera similar: si le damos las instrucciones css('top', oldTop - newTop) y css('top', 0) a la vez, las nuevas coordenadas reemplazarán las viejas y no pasará nada. Si queremos ver la animación, debemos forzar al navegador a redibujar el elemento después de moverlo la primera vez.

    Una forma sencilla de hacerlo es pedirle al navegador el offset del elemento.

    Vamos a probar de nuevo. Volvamos a la vista “Best” y votemos unos posts: ¡Deberías verlos moviéndose suavemente hacia arriba y hacia abajo como en un ballet!

    Animated reordering
    Animated reordering

    Commit 14-1

    Added post reordering animation.

    Aparecer y desaparecer

    Ahora que ya tenemos resuelta la reordenación más complicada, animar las inserciones y eliminaciones va ser muy sencillo.

    Primero, haremos aparecer nuevos posts (esta vez, por simplicidad, usaremos animaciones JavaScript):

    Template.postsList.onRendered(function () {
      this.find('.wrapper')._uihooks = {
        insertElement: function (node, next) {
          $(node)
            .hide()
            .insertBefore(next)
            .fadeIn();
        },
        moveElement: function (node, next) {
          //...
        }
      }
    });
    
    client/templates/posts/posts_list.js

    Para ver el resultado, podemos probar a insertar un post vía consola:

    Meteor.call('postInsert', {url: 'http://apple.com', title: 'Testing Animations'})
    
    Fading in new posts
    Fading in new posts

    Y ahora, haremos desaparecer los posts eliminados:

    Template.postsList.onRendered(function () {
      this.find('.wrapper')._uihooks = {
        insertElement: function (node, next) {
          $(node)
            .hide()
            .insertBefore(next)
            .fadeIn();
        },
        moveElement: function (node, next) {
          //...
        },
        removeElement: function(node) {
          $(node).fadeOut(function() {
            $(this).remove();
          });
        }
      }
    });
    
    client/templates/posts/posts_list.js

    De nuevo, para ver el efecto, prueba a eliminar algún post desde la consola (Posts.remove('algunPostId')).

    Fading out deleted posts
    Fading out deleted posts

    Commit 14-2

    Fade items in when they are drawn.

    Transiciones entre páginas

    Hemos creado animaciones para elementos dentro de una página. Pero, ¿qué pasa si queremos animar las transiciones entre páginas?

    Las transiciones entre páginas son trabajo del Iron Router. Haces click en un enlace y se reemplaza el contenido del ayudante {{> yield}} en layout.html

    Ocurre que, como cuando reemplazamos el comportamiento de Blaze para la lista de posts, ¡podemos hacer lo mismo para el elemento {{> yield}} y añadirle un efecto de transición entre rutas!

    Si queremos animar la entrada y salida entre dos páginas, debemos asegurarnos de que se muestran una por encima de la otra. Lo hacemos usando la propiedad position:absolute en el contenedor .page que envuelve a todas las plantillas de páginas.

    Piensa que no queremos que las páginas estén posicionadas de forma absoluta, porque de esta forma, se solaparían con la cabecera de la app. Así que establecemos la propiedad position:relative en el div #main que las contiene, de forma que el position:absolute de .page tome su origen desde #main.

    Para ahorrar tiempo, hemos añadido el código necesario a style.css:

    //...
    
    #main{
      position: relative;
    }
    .page{
      position: absolute;
      top: 0px;
      width: 100%;
    }
    
    //...
    
    client/stylesheets/style.css

    Es el momento de añadir el código para las transiciones entre páginas. Nos debe resultar familiar, puesto que es exactamente el mismo que para las inserciones y eliminaciones de posts:

    Template.layout.onRendered(function() {
      this.find('#main')._uihooks = {
        insertElement: function(node, next) {
          $(node)
            .hide()
            .insertBefore(next)
            .fadeIn();
        },
        removeElement: function(node) {
          $(node).fadeOut(function() {
            $(this).remove();
          });
        }
      }
    });
    
    client/templates/application/layout.js
    Transitioning in-between pages with a fade
    Transitioning in-between pages with a fade

    Commit 14-3

    Transition between pages by fading.

    Hemos visto unos pocos patrones para animar elementos en nuestra aplicación Meteor. Aunque no es una lista exhaustiva, con suerte, nos aportará una base sobre la que construir transiciones más elaboradas.