Animazioni

14

Percentuale Tradotta

In questo capitolo imparerai:

  • Vedi cosa succede nei retroscena quando Meteor scambia due elementi del DOM.
  • Impara come animare il riordinamento dei messaggi.
  • Impara come animare l'inserimento dei messaggi.
  • A questo punto abbiamo un sistema di votazione, conto punteggi e valutazione in tempo reale. Questo tuttavia risulta in un'esperienza erratica ed irritante, con messaggi che saltano da una parte all'altra della homepage. Per migliorare questa situazione useremo le animazioni.

    Meteor & il DOM

    Prima di iniziare la parte divertente (fare sì che le cose si muovano), dobbiamo capire come Meteor interagisce con il DOM (Document Object Model – la collezione di elementi HTML che costituiscono i contenuti di una pagina).

    È cruciale ricordarsi che gli elementi non possono essere spostati. Possono solamente essere eliminati e creati (nota che questa è una limitazione del DOM, non di Meteor). Quindi per dare l'illusione che gli elementi A e B cambino di posto, Meteor cancellerà l'elemento B e inserirà una copia nuova di zecca (B’) prima dell'elemento A.

    Questo rende l'animazione difficoltosa, siccome non puoi semplicemente animare B per muoverlo in una nuova posizione, perché B sarà scomparso non appena Meteor renderizza la pagina (che come sappiamo accade instantaneamente, grazie alla reactivity). Dovrai invece animare B’ mentre si muove dalla vecchia posizione di B verso la sua nuova posizione prima di A.

    Per scambiare i messaggi A e B (posizionati rispettivamente nelle posizioni p1 e p2), dobbiamo seguire i seguenti punti:

    1. Elimina B
    2. Nel DOM, prima di A crea B’
    3. Muovi B’ verso p2
    4. Muovi A verso p1
    5. Anima A verso p2
    6. Anima B’ verso p1

    Questi punti sono esposti in dettaglio nel seguente diagramma:

    Scambiare due messaggi
    Scambiare due messaggi

    Nota che nei punti 3 e 4 non stiamo animando A e B’ verso le loro posizioni, ma li stiamo “teleportando” istantaneamente. Questo darà l'illusione che B non è mai stato cancellato e posizionerà entrambi gli elementi così che possano essere animati verso la loro nuova posizione.

    Fortunatamente Meteor si prende cura dei punti 1 & 2, quindi ci dobbiamo preoccupare solamente dei punti da 3 a 6.

    Nei punti 5 e 6 inoltre, stiamo semplicemente spostando gli elementi nelle loro giuste posizioni. Quindi le uniche parti di cui ci dobbiamo veramente preoccupare sono i punti 3 e 4, cioè mandare gli elementi verso il punto iniziale dell'animazione.

    Tempismo giusto

    Fino ad ora abbiamo parlato di come animare i nostri messaggi, ma non di dove animarli.

    Per i punti 3 e 4, la risposta sta nella template callback rendered all'interno del gestore post_item.js, che è scatenato ogni volta che cambia la proprietà di un messaggio (nel nostro caso il punteggio).

    I punti 5 e 6 sono un pò più complessi. Pensaci su: se tu dicessi ad un automata di correre verso nord per 5 minuti, dopodichè di correre verso sud per 5 minuti, probabilmente l'automata dedurrà che siccome finirà nello stesso posto, potrebbe risparmiarsi le sue forze e non correre per niente.

    Quindi se vuoi assicurarti che il tuo automata corra per tutti e 10 i minuti, devi aspettare fino a che non ha corso i primi 5 minuti, e dopo dirgli di tornare indietro.

    Il browser funziona in una simile maniera: se simultaneamente gli diamo entrambe le istruzioni, le nuove coordinate semplicemente sostituirebbero quelle vecchie e non accadrebbe nulla. In altre parole, il browser ha bisogno di registrare i cambiamenti di posizione come punti separati nel tempo, altrimenti non sarà in grado di animarli.

    Meteor non fornisce una callback justAfterRendered, ma può imitarla usando Meteor.defer(), che semplicemente prende una funzione e pospone la sua esecuzione appena in tempo per registrarsi come un evento diverso.

    Posizionamento con i CSS

    Per animare i messaggi che si stanno riordinando per la pagina, dovremo avventurarci nel mondo dei CSS. È quindi d'ordine un breve ripasso sul posizionamento on i CSS.

    Gli elementi di una pagina sono predefiniti per avere un posizionamento statico. Gli elementi posizionati staticamente si adattano al flusso della pagina e le loro coordinate sullo schermo non possono essere cambiate o animate.

    Un posizionamento relativo dall'altro canto invece significa che l'elemento anche si adatta al flusso della pagina, ma può essere posizionato relativamente alla sua posizione originale*.

    Un posizionamento assoluto va un passo più in avanti e ti permette di specificare delle coordinate x/y relative al documento oppure al primo elemento padre posizionato relativamente o assolutamente.

    Per animare i nostri messaggi useremo un posizionamento relativo.

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

    Questo permette di fare facilmente i punti 5 e 6: dobbiamo semplicemente impostare top a 0px (il suo valore predefinito) così i nostri messaggi scorreranno indietro verso la loro posizione “normale”.

    Questo vuol dire che la nostra unica sfida è quella di calcolare da dove animarli (punti 3 e 4), relativamente alla loro nuova posizione. In altre parole, di quanto compensarli. Ma anche questo non è molto difficile: la giusta compensazione è semplicemente la posizione del messaggio precedente meno quella del nuovo.

    Position:absolute

    Per posizionare i nostri elementi potremmo anche usare position:absolute con un padre relativo. Ma un gran svantaggio degli elementi posizionati assolutamente è che sono completamente rimossi dal flusso della pagina, causando il collasso del loro contenitore padre come se fosse vuoto.

    A sua volta questo significa che dovremmo impostare l'altezza del contenitore artificialmente con Javascript, invece di lasciare che il browser aggiusti naturalmente gli elementi. Di conseguenza, ogni qual volta sia possibile è meglio rimanere con il posizionamento relativo.

    Richiamo totale

    Tuttavia abbiamo ancora un problema. Mentre l'elemento A persiste nel DOM e quindi può “ricordare” la sua posizione precedente, l'elemento B viene reincarnato e riprende vita sotto forma di B’, con la memoria cancellata.

    Meteor fortunatamente viene alla riscossa dandoci accesso all'oggetto istanza di template nella callback rendered. La documentazione di Meteor illustra:

    Nel body della callback, this è un oggetto istanza di template che è unico a questa occorrenza del template ed è persistente tra diversi renderings.

    Quello che faremo quindi, è trovare la posizione corrente di un messaggio nella pagina e salvare la posizione nell'oggetto istanza di template. In questa maniera, saremo in grado di sapere da dove animare il messaggio, anche quando viene eliminato e ricreato.

    Le istanze di template ci permetteno anche di accedere alla collezione di dati tramite la proprietà data. Questo ci tornerà utile per prendere il punteggio di un messaggio.

    Punteggio dei messaggi

    Abbiamo parlato del punteggio dei messaggi, ma questo “valore” effettivamente non esiste come una proprietà del messaggio, sicome è semplicemente una conseguenza dell'ordine dei messaggi che sono elencati nella nostra collezione. Dovremo in qualche maniera trovare un modo per far apparire questa proprietà dall'aria se vogliamo essere in grado di animare i messaggi a seconda del loro punteggio.

    Nota che siccome il punteggio è una proprietà relativa che dipende da come ordini i messaggi (un messaggio può essere valutato primo mentre si ordina per data, ma terzo quando si ordina per punteggio), non possiamo mettere questa proprietà rank nel database stesso.

    Idealmente metteremmo la proprietà nelle nostre collezioni newPosts e topPosts, ma Meteor al momento non offre un meccanismo conveniente per farlo.

    Inseriremo invece rank all'ultimo momento possibile, nel postList template manager:

    Template.postsList.helpers({
      postsWithRank: function() {
        this.posts.rewind();
        return this.posts.map(function(post, index, cursor) {
          post._rank = index;
          return post;
        });
      }
    });
    
    /client/views/posts/posts_list.js

    Invee di ritornare il cursore Posts.find({}, {sort: {submitted: -1}, limit: postsHandle.limit()}) come nei nostri posts helpers precedenti, postsWithRank prende il cursore e aggiunge la proprietà _rank per ognuno dei suoi documenti.

    E non dimenticarti di aggiornare il template postsList:

    <template name="postsList">
      <div class="posts">
        {{#each postsWithRank}}
          {{> postItem}}
        {{/each}}
    
        {{#if nextPath}}
          <a class="load-more" href="{{nextPath}}">Load more</a>
        {{/if}}
      </div>
    </template>
    
    /client/views/posts/posts_list.html

    Sii cortese, riavvolgi

    Meteor è uno dei web frameworks più progressisti e all'avanguardia che ci siano. Ma una delle sue funzionalità, la funzione rewind(), sembra essere un ritorno ai giorni passati delle video cassette e dei VCRs.

    Ogni volta che usi un cursore con forEach(), map(), oppure fetch(), dovrai riavvolgerlo prima di poterlo usare di nuovo.

    E in alcuni casi è meglio stare sul sicuro e riavvolgere il cursore in maniera preventiva piuttosto che rischiare un errore.

    Mettere tutto insieme

    Ora possiamo mettere tutto insieme usando la template callback rendered del manager in post_item.js per la logica della nostra animazione:

    Template.postItem.helpers({
      //...
    });
    
    Template.postItem.rendered = function(){
      // anima questo messaggio dalla posizione precedente a quella nuova
      var instance = this;
      var rank = instance.data._rank;
      var $this = $(this.firstNode);
      var postHeight = 80;
      var newPosition = rank * postHeight;
    
      // se l'elemento ha una currentPosition (non è il primo rendering)
      if (typeof(instance.currentPosition) !== 'undefined') {
        var previousPosition = instance.currentPosition;
        // calcola la differenza tra vecchia e nuova posizione ed invia lì l'elemento
        var delta = previousPosition - newPosition;
        $this.css("top", delta + "px");
      }
    
      // lascia che venga disegnato nella vecchia posizione, dopodichè...
      Meteor.defer(function() {
        instance.currentPosition = newPosition;
        // porta l'elemento indietro alla sua posizione originale
        $this.css("top",  "0px");
      });
    };
    
    Template.postItem.events({
      //...
    });
    
    /client/views/posts/post_item.js

    Commit 14-1

    Added post reordering animation.

    Seguire non dovrebbe essere troppo difficile se fai riferimento al diagramma precedente.

    Nota che siccome abbiamo impostato la proprietà currentPosition dell'istanza di template nella callback defer, questa proprietà non esisterà al primo rendering del frammento di template. Ma non è un problema siccome in ogni modo non siamo interessati ad animare il primo rendering.

    Apri il sito e prova a votare. Dovresti vedere i messaggi, che con grazia da ballerine, si spostano gentilmente su e giù!

    Animare nuovi messaggi

    Ora i nostri messaggi si riordinano correttamente, ma non abbiamo ancora un'animazione per un “nuovo messaggio”. Invece di far spuntare immediatamente i nuovi messaggi, facciamoli apparire gradualmente.

    In verità questo è più complesso di quel che sembra. Il problema è che la callback rendered di Meteor viene scatenata in due casi separati:

    1. Quando un nuovo template viene inserito nel DOM
    2. Quando i dati del template vengono cambiati

    Solamente il primo caso dovrebbe essere animato, a meno che tu non voglia un'interfaccia utente che si illumina come un albero di natale ogni volta che i tuoi dati cambiano.

    Assicuriamoci quindi di animare solamente i messaggi che sono veramente nuovi e non quelli che vengono ri-renderizzati perché i loro dati sono cambiati. Stiamo già testando per la presenza di una variabile d'istanza (che è impostata solamente dopo il primo render), quindi dobbiamo solo tornare indietro alla nostra callback rendered e aggiungere un blocco else:

    Template.postItem.helpers({
      //...
    });
    
    Template.postItem.rendered = function(){
      // anima questo messaggio dalla posizione precedente a quella nuova
      var instance = this;
      var rank = instance.data._rank;
      var $this = $(this.firstNode);
      var postHeight = 80;
      var newPosition = rank * postHeight;
    
      // se l'elemento ha una currentPosition (non è il primo rendering)
      if (typeof(instance.currentPosition) !== 'undefined') {
        var previousPosition = instance.currentPosition;
        // calcola la differenza tra vecchia e nuova posizione ed invia lì l'elemento
        var delta = previousPosition - newPosition;
        $this.css("top", delta + "px");
      } else {
        // è il primo evento render in assoluto, quindi nascondi l'elemento
        $this.addClass("invisible");
      }
    
      // lascia che venga disegnato nella vecchia posizione, dopodichè...
      Meteor.defer(function() {
        instance.currentPosition = newPosition;
        // porta l'elemento indietro alla sua posizione originale
        $this.css("top",  "0px").removeClass("invisible");
      });
    };
    
    Template.postItem.events({
      //...
    });
    
    /client/views/posts/post_item.js

    Commit 14-2

    Fade items in when they are drawn.

    Nota che la removeClass("invisible") che abbiamo aggiunto nella funzione defer() verrà eseguita ad ogni rendering. Ma farà qualcosa solamente se la classe .invisible è presente sull'elemento, il che è vero solamente la prima volta che è renderizzato.

    CSS & JavaScript

    Avrai notato che stiamo usando una classe CSS .invisible per scatenare l'animazione invece di animare direttamente la proprietà CSS opacity come abbiamo fatto per top. Questo è perché per top avevamo bisogno di animare la proprietà usando un valore specifico che dipende dai dati dell'istanza.

    Dall'altro canto qui vogliamo solo mostrare e nascondere un elemento, indipendentemente dai suoi dati. Siccome è una buona idea lasciare il più possibile CSS fuori da Javascript, qui aggiungeremo e toglieremo solamente la classe e specificheremo i dettagli dell'animazione nel nostro stylesheet.

    Finalmente dovremmo avere l'animazione che volevamo! Lancia la tua app e provala! Puoi anche divertirti con le classi .post e .post.invisible per vedere se riesci a trovare altri modi di animare. Suggerimento: CSS easing functions è un buon punto per cominciare!