Анимации

14

На данный момент переведено

В этой главе вы:

  • Узнаете что происходит за кулисами когда Meteor меняет местами два DOM элемента.
  • Научитесь анимировать реорганизацию постов.
  • Научитесь анимировать добавление новых постов.
  • В нашем приложении уже есть голосование в реальном времени, голосование за посты и рейтинги лучших постов. Из-за такого обилия функционала наши посты прыгают по странице и это не производит хорошего впечатления на пользователей. В этой главе мы научимся добавлять анимацию, чтобы сгладить все перемещения элементов.

    Meteor и DOM

    Прежде, чем приступить к самой веселой части (анимировании элементов приложения), нам необходимо понять, как Meteor взаимодействует с DOM (или Document Object Model - набор элементов HTML, представляющие содержимое страниц).

    Важный момент, который следует усвоить, заключается в том, что элементы DOM на самом деле не могут быть перемещены; однако их можно удалять и создавать (запомните, что это ограничение DOM, а не Meteor). И чтобы создать иллюзию перемены элементов A и B местами, Meteor будет удалять элемент B и создавать его новую копию перед элементом A.

    Из-за этого процесс анимации становится непростым. Мы не можем просто переместить элемент B в его новую позицию, так как B будет удален сразу же, как только Meteor обновит страницу (которая, благодаря реактивности, обновится мгновенно). Но не волнуйтесь, мы найдем решение.

    Советский бегун

    Был 1980 год, самый разгар Холодной Войны. Олимпийские игры проходили в Москве, и Советский Союз был полон решимости победить любой ценой в забеге на 100 метров. Для этого группа лучших советских ученых экипировала одного из своих бегунов телепортером, чтобы мгновенно переместить его к финишной черте сразу после выстрела.

    К счастью, судьи сразу заметили нарушение, и атлету пришлось телепортироваться обратно на старт, чтобы быть допущенным к участию в гонке, как все остальные.

    Мои исторические источники ненадежны, поэтому вам не стоит принимать эту историю за чистую монету. Но попробуйте держать в уме аналогию про “советского бегуна с телепортером”, пока читаете эту главу.

    Разбиваем процесс на кусочки

    Когда Meteor получит обновление и реактивно изменит DOM, наш пост, словно советский бегун, мгновенно перенесется в его финальное положение. Но ни на Олимпийских играх, ни в нашем приложении мы не можем просто телепортировать все подряд. Поэтому, мы телепортируем элемент назад к стартовой позиции и будем двигать его (другими словами, анимировать) к финишной черте.

    Итак, чтобы поменять посты A и B (расположенные в позициях p1 и p2 соответственно), мы совершим следующие действия:

    1. Удалим B
    2. Создадим B’ перед A, в DOM
    3. Телепортируем B’ в p2
    4. Телепортируем A в p1
    5. Анимируем A в p2
    6. Анимируем B’ в p1

    Следующая диаграмма объясняет эти шаги в деталях:

    Меняем местами два поста
    Меняем местами два поста

    Повторюсь, в шагах 3 и 4 мы не анимируем A и B’ в их позиции, а мгновенно их “тепепортируем”. Так как это происходит мгновенно, пользователю может показаться что они никогда не были удалены, и корректные положения обоих элементов анимируются вперед к их новым значениям.

    К счастью, Meteor берет на себя заботу о двух первых шагах, так что нам нужно подумать только о шагах с 3 по 6.

    Более того, в шагах 5 и 6 все что нам нужно сделать это передвинуть элементы в их должное положение. Таким образом, в действительности, нам нужно подумать только о пунктах 3 и 4, т.е., отправить элементы в их начальную позицию анимации.

    Правильный тайминг

    До этого момента мы говорили о том, как анимировать наши посты, но не когда анимировать их.

    Для шагов 3 и 4 ответ будет следующим - всякий раз, когда изменяется свойство поста ’_rank’ (от которого и зависит позиция поста в списке).

    Шаги 5 и 6 будут хитрее. Представьте следующую вещь: если вы сообщите абсолютно логичному андроиду бежать на север 5 минут, а затем бежать 5 минут на на юг, он скорее всего сообразит, что если он должен закончить пробежку на стартовом месте, будет проще сохранить энергию и не бежать вовсе.

    И если вы хотите убедится, что ваш андроид бежал 10 минут, вам придется подождать, пока он не пробежит первые 5 минут, и затем приказать ему бежать обратно.

    Браузер работает похожим образом: если мы просто дадим обе инструкции одновременно, то старые координаты будут переписаны новыми и ничего не случится. Другими словами, браузер должен регистрировать изменение позиций как отдельные точки во времени, иначе он не сможет их анимировать.

    Meteor не предоставляет встроенного коллбека на этот случай, но мы можем имитировать его, используя Meteor.setTimeout(), который просто берет функцию и откладывает ее выполнение на несколько миллисекунд.

    Позиционирование элементов с помощью CSS

    Чтобы анимировать реорганизацию постов в списке, нам следует вторгнуться на территорию CSS. Давайте быстро повторим основные правила, как элементы позиционируются на странице с помощью CSS.

    Элементы страницы по умолчанию используют статичное - static позиционирование. Статически размещенный элемент просто располагается в потоке на странице, и его экранные координаты не могут быть изменены или анимированны.

    Relative - относительное позиционирование в свою очередь подразумевает, что элемент позиционируется традиционным способом на странице, но может быть также сдвинут относительно своего изначального положения.

    Absolute - абсолютное позиционирование делает еще один шаг вперед и позволяет вам задать конкретные координаты x/y относительно корневого документа или первого абсолютно или относительно позиционированного элемента-родителя.

    Мы будем использовать относительное позиционирование для анимации наших постов. Мы уже позаботились о CSS для вас, но если вам хочется добавить стили самостоятельно, просто добавьте этот код в ваш CSS файл:

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

    Шаги 5-6 будут совсем простыми: вам нужно только сбросить значение top на 0px (это его значение по-умолчанию), и наши посты плавно сдвинутся обратно к их “нормальной” позиции.

    Другими словами, наша задача - понять, откуда анимировать посты (шаги 3 и 4). То есть, на сколько пикселей нужно сдвинуть посты. Но это совсем несложно: правильный сдвиг можно вычислить, отняв координаты новой позиции поста от предыдущей.

    Position:absolute

    Мы могли бы использовать position:absolute вместе с относительным родительским элементом для позиционирования наших постов. Но у абсолютного позиционирования есть один большой минус - они совершенно выпадают из структуры страницы, заставляя родительский элемент сжаться, словно внутри ничего нет.

    Чтобы компенсировать это, нам придется воспользоваться арсеналом JavaScript, искуственно выставив размеры родительского контейнера - вместо того, чтобы дать браузеру возможность позиционировать элементы естественным образом. Так что лучше использовать относительное позиционирование.

    Вспомнить все

    Есть еще одна загвоздка. В то время как элемент А остается в DOM и, таким образом, “помнит” свою предыдущую позицию, элемент B переживает реинкарнацию и возвращается к жизни как новый элемент B’, с абсолютно стертой памятью.

    Чтобы решить эту задачу, мы воспользуемся локальной коллекцией. В нее мы сохраним текущую позицию поста на странице. Локальная коллекция работает точно так же, как и обычная коллекция Meteor, за исключением того, что она остается только в памяти браузера (не отправляет данные на сервер). Таким образом, даже если пост удален и воссоздан заново, мы все еще сможем понять, с какого места на странице его надо анимировать.

    Рейтинг постов

    Мы много говорили про рейтинг постов, но этот “рейтинг” не существует как отдельное свойство поста, а является всего лишь следствием того, в каком порядке посты сохранены в нашей коллекции. Если мы хотим анимировать посты согласно их рейтингу, нам надо наколдовать это свойство из воздуха.

    Обратите внимание: мы не можем просто добавить свойство rank в базу данных, так как рейтинг это относительное свойство, которое просто зависит от того, как мы сортируем посты (то есть, отдельно взятый пост может иметь первое место при сортировке по дате, но третье место при сортировке по количеству голосов).

    В идеале, мы хотели бы добавить это свойство в коллекции newPosts и topPosts, но Meteor на данный момент не предлагает подходящего механизма для этого.

    Вместо этого мы добавим свойство rank на самом последнем шаге, в менеджере шаблона postList:

    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

    Вместо того чтобы просто вернуть курсор Posts.find({}, {sort: {submitted: -1}, limit: postsHandle.limit()}) как наш предыдущий хелпер posts, postsWithRank принимает курсор и добавляет свойство _rank к каждому из его документов.

    Не забудьте обновить шаблон 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

    Пожалуйста, перемотайте

    Meteor является одной из самых передовых сред разработки веб-приложений. Но одна из его особенностей как будто пришла со времен видеомагнитофонов и записи на видеокассеты. Мы говорим о функции rewind().

    Каждый раз, когда вы вызываете функции forEach(), map(), или fetch() для курсора, вам нужно перемотать курсор на место перед тем, как им можно воспользоваться снова.

    Поэтому, на всякий случай, курсор стоит перематывать каждый раз и не рисковать возможностью ошибиться.

    Собираем все вместе

    Так как наша анимация будет влиять на CSS атрибуты и классы нашего DOM элемента, мы добавим динамический хелпер {{attributes}} нашему шаблону postItem:

    <template name="postItem">
      <div class="post" {{attributes}}>
    
      //..
    
    </template>
    
    /client/views/posts/post_item.html

    Используя хелпер {{attributes}}, мы также открываем скрытое свойство Spacebars - любое свойство возвращаемого объекта attributes будет автоматически соотнесено с HTML атрибутами DOM элемента (такими как class, style, и так далее).

    Давайте соберем все вместе, создав хелпер attributes:

    var POST_HEIGHT = 80;
    var Positions = new Meteor.Collection(null);
    
    Template.postItem.helpers({
    
      //..
    
      attributes: function() {
        var post = _.extend({}, Positions.findOne({postId: this._id}), this);
        var newPosition = post._rank * POST_HEIGHT;
        var attributes = {};
    
        if (! _.isUndefined(post.position)) {
          var offset = post.position - newPosition;
          attributes.style = "top: " + offset + "px";
          if (offset === 0)
            attributes.class = "post animate"
        }
    
        Meteor.setTimeout(function() {
          Positions.upsert({postId: post._id}, {$set: {position: newPosition}})
        });
    
        return attributes;
      }
    });
    
    //..
    
    /client/views/posts/post_item.js

    В начале нашего документа мы устанавливаем значение height - высоту для каждого DOM элемента - то есть, наших div элементов с постами. Если что-то повлияет на данное значение height, например, длинный текст заголовка поста займет больше одной строки, наша анимация сломается. Но на данный момент, чтобы слегка упростить вещи, мы предположим, что каждый пост будет ровно 80 пикселей в высоту.

    Далее мы объявляем локальную коллекцию под названием Positions. Обратите внимание как мы передаем null в качестве аргумента - это дает Meteor знать что мы создаем именно локальную коллекцию (только для клиента).

    Все готово, чтобы создать наш хелпер attributes.

    Расписание движения

    Иногда бывает непросто понять, когда кусочек реактивного кода будет запущен. Давайте подробнее взглянем на хелпер attributes.

    Как любой хелпер, он будет запущен, когда шаблон будет отрисован. Из-за его зависимости к атрибуту _rank он также будет перезапущен каждый раз, когда рейтинг поста изменится. И, наконец, его зависимость от коллекции Positions означает, что он будет перезапущен, когда данный объект будет отредактирован.

    Следовательно, хелпер может быть запущен два или три раза подряд. Такое положение вещей может прозвучать расточительным, но это именно то, как реактивность работает. Как только вы привыкнете к ней, это станет неотъемлемой частью разработки приложения, подходом к написанию кода.

    Хелпер Attributes

    Для начала мы найдем позицию поста в коллекции Positions и расширим this (который, в данном случае, соотносится с текущим постом) результатом нашего запроса. Мы воспользуемся атрибутом _rank для расчета новых координат DOM элемента относительно начала страницы.

    Затем мы должны позаботиться о двух сценариях - либо хелпер запущен потому, что шаблон отрисовывается (А), или он запущен реактивно, потому что изменено значение атрибута (B).

    Нам нужно анимировать элемент только в случае B, поэтому для начала мы убедимся, что свойство post.position существует и имеет значение (как именно оно было создано, мы скоро узнаем).

    Вдобавок ко всему, сценарий В имеет два возможных варианта: В1 и В2; либо мы телепортируем наш элемент DOM назад на стартовую позицию (его предыдущую позицию), либо мы анимируем его с предыдущей позиции на новую.

    Тут-то и стоит воспользоваться переменной offset. Так как мы используем относительное - relative позиционирование, нужно рассчитать новые координаты относительно текущей позиции. Этого можно достичь вычитанием новой позиции из предыдущей.

    Чтобы отличить случай В1 от В2, мы просто проверим на значение свойство offset: если offset отличается от 0, это означает что мы отодвигаем элемент от его первоначального положения, и мы можем добавить класс animate к этому элементу, чтобы его перемещение было анимировано с помощью волшебства CSS transition.

    Работа с функцией setTimeout

    Эти три ситуации (А, В1 и В2) запускаются реактивным способом, когда значение определенных атрибутов меняется. В этом случае, функция setTimeout запускает переопределение реактивного контекста, изменяя коллекцию Positions.

    Поэтому, когда пользователь впервые загружает страницу, весь реактивный процесс происходит следующим образом:

    • Хелпер attributes запускается в первый раз.
    • Значение post.position не определено (A).
    • setTimeout запускается и определяет значение post.position.
    • Хелпер attributes реактивно перезапускается.
    • Перемещения поста не произошло, поэтому значение параметра offset меняется с 0 на 0 (анимация не происходит) (В2).

    А вот что происходит, когда пользователь голосует за пост:

    • Значение _rank меняется, что запускает переопределение хелпера attributes.
    • Значение post.position определено (B).
    • Значение offset не равно 0, значит анимации нет (B1).
    • Запускается setTimeout, который переопределяет значение post.position.
    • Хелпер attributes реактивно перезапускается.
    • Значение offset меняется назад на 0 (с анимацией) (B2).

    Откройте сайт и попробуйте проголосовать за несколько постов. Вы должны увидеть, как посты с плавной грацией перемещаются вверх и вниз.

    Коммит 14-1

    Добавили анимацию к реорганизации списка постов.

    Анимация новых постов

    Наши посты правильно реорганизуются, но у нас все еще отсутствует анимация для новых постов. Вместо того, чтобы скучно добавлять посты поверх списка, давайте добавим эффект плавного появления.

    //..
    
    attributes: function() {
      var post = _.extend({}, Positions.findOne({postId: this._id}), this);
      var newPosition = post._rank * POST_HEIGHT;
      var attributes = {};
    
      if (_.isUndefined(post.position)) {
        attributes.class = 'post invisible';
      } else {
        var delta = post.position - newPosition;
        attributes.style = "top: " + delta + "px";
        if (delta === 0)
          attributes.class = "post animate"
      }
    
      Meteor.setTimeout(function() {
        Positions.upsert({postId: post._id}, {$set: {position: newPosition}})
      });
    
      return attributes;
    }
    
    //..
    
    /client/views/posts/post_item.js

    Мы изолируем сценарий (А) и добавляем нашему элементу CSS класс invisible. Когда хелпер реактивно перезапускается, и элемент получает класс animate, разница в значениях прозрачности (opacity) будет анимирована, и элемент плавно проявится на странице.

    Коммит 14-2

    Плавное проявление постов.

    CSS & JavaScript

    Вы наверное обратили внимание, что мы используем CSS класс .invisible для запуска анимации вместо того, чтобы анимировать CSS параметр opacity напрямую, как мы это делали с параметром top. Это все потому, что нам нужно было анимировать top до определенного значения, которое зависело от данных конкретного поста.

    С другой стороны, в данном случае мы только хотим показать или спрятать элемент вне зависимости от его содержимого. Хорошей практикой обычно является держать стили CSS и логику JavaScript отдельно друг от друга, поэтому здесь мы будем только добавлять и удалять класс элемента, а саму анимацию определим в стилях CSS.

    Наша анимация должна работать так, как и задумывалась с самого начала. Загрузите приложение и попробуйте! Также вы можете поиграть с классами .post.animated и попробовать разные эффекты перехода - transitions. Подсказка: CSS функции с разнообразными кривыми анимации отличное место для старта!