Unetway

Анимирование списков, появления и исчезновения

Vue позволяет анимировать переходы при добавлении, обновлении и удалении объектов DOM.

  • автоматически использовать CSS-классы для анимаций и переходов
  • интегрировать сторонние библиотеки CSS-анимаций, например, animate.css
  • использовать JavaScript для работы с DOM напрямую в transition-хуках
  • интегрировать сторонние библиотеки JavaScript-анимаций, например, Velocity.js

Анимирование одиночного элемента или компонента

Компонент-обёртка transition, позволяет анимировать появление и исчезновение элемента или компонента в следующих случаях:

  • условный рендеринг (используя v-if)
  • условное отображение (используя v-show)
  • динамические компоненты
  • корневые элементы компонентов
<div id="demo">
  <button v-on:click="show = !show">
    Переключить
  </button>
  <transition name="fade">
    <p v-if="show">hello</p>
  </transition>
</div>
new Vue({
  el: '#demo',
  data: {
    show: true
  }
})
.fade-enter-active, .fade-leave-active {
  transition: opacity .5s
}
.fade-enter, .fade-leave-to /* .fade-leave-active до версии 2.1.8 */ {
  opacity: 0
}

Когда элемент, завёрнутый в компонент transition, вставляется или удаляется, происходят действия:

  1. Vue автоматически узнаёт, применены ли к элементу CSS-переходы или анимации. Если да, CSS-классы будут обновлены в нужные моменты времени.
  2. Когда для компонента указаны хуки JavaScript, они будут вызваны в соответствующие моменты времени.
  3. Если не обнаружено ни CSS-переходов и анимаций, ни JavaScript-хуков, операции DOM для вставки или удаления элемента будут выполнены в следующем анимационном фрейме 

Речь идёт об анимационном фрейме браузера, отличном от используемой во Vue концепции nextTick

Классы переходов
Есть 6 классов, применяющихся в анимациях появления и исчезновения элементов:

  • v-enter - начало анимации появления элемента. Класс добавляется перед вставкой элемента, а удаляется в следующем фрейме после вставки.
  • v-enter-active - анимация появления элемента активна. Класс остаётся в течение всей анимации, добавляется перед вставкой элемента, удаляется когда переход или анимация прекратились. 
  • v-enter-to - анимация появления элемента завершается. Класс добавляется в следующем фрейме после вставки элемента, удаляется после завершения перехода или анимации.
  • v-leave - начало анимации исчезновения элемента. Класс добавляется как только началась анимация исчезновения, а удаляется в следующем фрейме после этого.
  • v-leave-active - анимация исчезновения элемента активна. Класс остаётся в течение всей анимации, добавляется как только начинается анимация исчезновения, а удаляется когда переход или анимация прекратились
  • v-leave-to - анимация исчезновения элемента завершается. Класс добавляется в следующем фрейме после начала анимации исчезновения, удаляется после завершения перехода или анимации.

Для каждого класса указывается префикс, соответствующий имени перехода. Префикс v- применяется по умолчанию, если элемент <transition> используется без указания параметра name . Например, для <transition name="my-transition"> вместо класса v-enter будет применяться my-transition-enter.
v-enter-active и v-leave-active позволяет указывать различные анимационные эффекты для переходов появления и исчезновения элемента. 

CSS-переходы

Самый распространённый тип анимации - это CSS-переходы. 

<div id="example-1">
  <button @click="show = !show">
    Переключить рендеринг
  </button>
  <transition name="slide-fade">
    <p v-if="show">hello</p>
  </transition>
</div>
new Vue({
  el: '#example-1',
  data: {
    show: true
  }
})
/* Анимации появления и исчезновения могут иметь */
/* различные продолжительности и динамику.       */
.slide-fade-enter-active {
  transition: all .3s ease;
}
.slide-fade-leave-active {
  transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0);
}
.slide-fade-enter, .slide-fade-leave-to
/* .slide-fade-leave-active до версии 2.1.8 */ {
  transform: translateX(10px);
  opacity: 0;
}

CSS-анимации

CSS-анимации применяются аналогично CSS-переходам, с одним отличием: v-enter удаляется не сразу же после вставки элемента, а при наступлении события animationend.

<div id="example-2">
  <button @click="show = !show">Переключить отображение</button>
  <transition name="bounce">
    <p v-if="show">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris facilisis enim libero, at lacinia diam fermentum id. Pellentesque habitant morbi tristique senectus et netus.</p>
  </transition>
</div>
new Vue({
  el: '#example-2',
  data: {
    show: true
  }
})
.bounce-enter-active {
  animation: bounce-in .5s;
}
.bounce-leave-active {
  animation: bounce-in .5s reverse;
}
@keyframes bounce-in {
  0% {
    transform: scale(0);
  }
  50% {
    transform: scale(1.5);
  }
  100% {
    transform: scale(1);
  }
}

Пользовательские классы переходов

Еще можно использовать пользовательские классы переходов, указывая атрибуты:

  • enter-class
  • enter-active-class
  • enter-to-class 
  • leave-class
  • leave-active-class
  • leave-to-class 

Стандартные названия классов будут переопределены, что полезно для комбинирования системы анимированных переходов Vue с возможностями сторонних библиотек CSS-анимаций.

<link href="https://cdn.jsdelivr.net/npm/animate.css@3.5.1" rel="stylesheet" type="text/css">

<div id="example-3">
  <button @click="show = !show">
    Переключить рендеринг
  </button>
  <transition
    name="custom-classes-transition"
    enter-active-class="animated tada"
    leave-active-class="animated bounceOutRight"
  >
    <p v-if="show">hello</p>
  </transition>
</div>
new Vue({
  el: '#example-3',
  data: {
    show: true
  }
})

Совместное использование переходов и анимаций

Чтобы Vue знал о завершении анимации, ему нужна установка подписчиков на события. Таким событием будет либо transitionend, либо animationend. Если используется только один из подходов, Vue автоматически определит правильный тип.

Иногда может потребоваться использование двух подходов на одном элементе. Например, CSS-анимация под управлением Vue при появлении или исчезновении элемента может соседствовать с эффектом CSS-перехода при наведении курсора мыши на элемент. В этом случае нужно явно указать тип события, на которое должен ориентироваться Vue. Атрибут type получит значение animation или transition.

Указание длительности перехода

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

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

В таких случаях с помощью опции duration на компоненте <transition> можно явно указать продолжительность перехода (в миллисекундах).

<transition :duration="1000">...</transition>

Еще можно указать отдельные значения продолжительностей начала и окончания перехода:

<transition :duration="{ enter: 500, leave: 800 }">...</transition>

JavaScript-хуки

Можно указывать JavaScript-хуки:

<transition
  v-on:before-enter="beforeEnter"
  v-on:enter="enter"
  v-on:after-enter="afterEnter"
  v-on:enter-cancelled="enterCancelled"

  v-on:before-leave="beforeLeave"
  v-on:leave="leave"
  v-on:after-leave="afterLeave"
  v-on:leave-cancelled="leaveCancelled"
>
  <!-- ... -->
</transition>
// ...
methods: {
  // --------
  // ПОЯВЛЕНИЕ
  // --------

  beforeEnter: function (el) {
    // ...
  },
  // коллбэк done не обязательно использовать, если
  // анимация или переход также определены в CSS
  enter: function (el, done) {
    // ...
    done()
  },
  afterEnter: function (el) {
    // ...
  },
  enterCancelled: function (el) {
    // ...
  },

  // --------
  // ИСЧЕЗНОВЕНИЕ
  // --------

  beforeLeave: function (el) {
    // ...
  },
  // коллбэк done не обязательно использовать, если
  // анимация или переход также определены в CSS
  leave: function (el, done) {
    // ...
    done()
  },
  afterLeave: function (el) {
    // ...
  },
  // leaveCancelled доступна только для v-show
  leaveCancelled: function (el) {
    // ...
  }
}

Хуки можно применять самостоятельно, а также вместе с CSS-переходами и анимациями.

Если переход основан только на JavaScript, нужно обязательно вызывать коллбэки done в хуках enter и leave. Если этого не сделать, хуки будут вызваны синхронно, и переход закончится раньше, чем отработает код.

Лучше явным образом указывать v-bind:css="false" для переходов, основанных только на JavaScript. Это позволит Vue не тратить время на определение параметров CSS и убережёт от случайного взаимовлияния CSS-правил и JS-перехода.

Рассмотрим JavaScript-переход с использованием Velocity.js:

<!--
Velocity работает примерно так же, как и jQuery.animate,
и весьма удобен для создания JavaScript-анимаций
-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.2.3/velocity.min.js"></script>

<div id="example-4">
  <button @click="show = !show">
    Переключить
  </button>
  <transition
    v-on:before-enter="beforeEnter"
    v-on:enter="enter"
    v-on:leave="leave"
    v-bind:css="false"
  >
    <p v-if="show">
      Демо
    </p>
  </transition>
</div>
new Vue({
  el: '#example-4',
  data: {
    show: false
  },
  methods: {
    beforeEnter: function (el) {
      el.style.opacity = 0
    },
    enter: function (el, done) {
      Velocity(el, { opacity: 1, fontSize: '1.4em' }, { duration: 300 })
      Velocity(el, { fontSize: '1em' }, { complete: done })
    },
    leave: function (el, done) {
      Velocity(el, { translateX: '15px', rotateZ: '50deg' }, { duration: 600 })
      Velocity(el, { rotateZ: '100deg' }, { loop: 2 })
      Velocity(el, {
        rotateZ: '45deg',
        translateY: '30px',
        translateX: '30px',
        opacity: 0
      }, { complete: done })
    }
  }
})

Переходы при первичном рендеринге

Чтобы пользователь увидел анимацию перехода и при изначальном рендеринге, нужно добавить атрибут appear:

<transition appear>
  <!-- ... -->
</transition>

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

<transition
  appear
  appear-class="custom-appear-class"
  appear-to-class="custom-appear-to-class" (в версии 2.1.8+)
  appear-active-class="custom-appear-active-class"
>
  <!-- ... -->
</transition>

Аналогичное для хуков:

<transition
  appear
  v-on:before-appear="customBeforeAppearHook"
  v-on:appear="customAppearHook"
  v-on:after-appear="customAfterAppearHook"
  v-on:appear-cancelled="customAppearCancelledHook"
>
  <!-- ... -->
</transition>

Переходы между элементами

Рассмотрим переходы с помощью директив v-if/v-else. Например, переход от списка к сообщению, что список пуст:

<transition>
  <table v-if="items.length > 0">
    <!-- ... -->
  </table>
  <p v-else>Жаль, но ничего не найдено.</p>
</transition>

Это будет работать, но нужно знать о нескольких моментах:

При переключении между элементами, использующими одноимённые теги, нужно указать Vue, что это различные элементы, установив уникальные значения атрибута key. 
Иначе, компилятор Vue из соображений эффективности только поменяет содержимое элемента. Считается хорошим тоном всегда оборачивать множественные теги в компонент <transition>.

<transition>
  <button v-if="isEditing" key="save">
    Сохранить
  </button>
  <button v-else key="edit">
    Редактировать
  </button>
</transition>

В этом случае можно использовать атрибут key для перехода между разными состояниями элемента. Вместо использования v-if и v-else, можно переписать пример выше вот так:

<transition>
  <button v-bind:key="isEditing">
    {{ isEditing ? 'Сохранить' : 'Редактировать' }}
  </button>
</transition>

Можно не ограничиваться двумя вариантами, и организовать переходы между любым количеством элементов, многократно используя v-if или привязывая единственный элемент к динамическому свойству:

<transition>
  <button v-if="docState === 'saved'" key="saved">
    Редактировать
  </button>
  <button v-if="docState === 'edited'" key="edited">
    Сохранить
  </button>
  <button v-if="docState === 'editing'" key="editing">
    Отмена
  </button>
</transition>

Аналогично можно записать:

<transition>
  <button v-bind:key="docState">
    {{ buttonMessage }}
  </button>
</transition>
// ...
computed: {
  buttonMessage: function () {
    switch (this.docState) {
      case 'saved': return 'Редактировать'
      case 'edited': return 'Сохранить'
      case 'editing': return 'Отмена'
    }
  }
}

Режимы переходов

Во время перехода от кнопки “on” к кнопке “off” одновременно отображаются обе кнопки: одна — исчезая, другая — появляясь. Так <transition> ведёт себя по умолчанию.
Иногда это поведение подходит, например если оба элемента абсолютно спозиционированы в одном и том же месте:
Тем не менее, одновременное сокрытие и появление элементов — это не всегда то, чего хочется. Поэтому Vue предоставляет альтернативные режимы перехода:

  • in-out - сначала появляется новый элемент, и только после этого исчезает старый.
  • out-in - сначала исчезает старый элемент, и только после этого появляется новый.

Давайте теперь изменим переход для наших on/off кнопок, используя out-in:

<transition name="fade" mode="out-in">
  <!-- ... кнопки ... -->
</transition>

Изменив всего лишь один атрибут, мы “починили” анимацию перехода, не прибегая к редактированию стилей. Режим in-out применяется не столь часто, но для достижения некоторых эффектов и он может быть полезен. 

Переходы между компонентами

Переходы между компонентами осуществляются еще проще, даже без атрибута key. Для этого нужно завернуть динамический компонент в <transition>:

<transition name="component-fade" mode="out-in">
  <component v-bind:is="view"></component>
</transition>
new Vue({
  el: '#transition-components-demo',
  data: {
    view: 'v-a'
  },
  components: {
    'v-a': {
      template: '<div>Компонент A</div>'
    },
    'v-b': {
      template: '<div>Компонент B</div>'
    }
  }
})
.component-fade-enter-active, .component-fade-leave-active {
  transition: opacity .3s ease;
}
.component-fade-enter, .component-fade-leave-to
/* .component-fade-leave-active до версии 2.1.8 */ {
  opacity: 0;
}

Переходы в списках

Мы уже разобрались с переходами для:

  • Одиночных элементов
  • Множеств элементов, когда единовременно не может отображаться более одного элемента из множества

В случае когда у нас есть целый список элементов, который мы бы хотели отображать одновременно, например директивой v-for, мы используем компонент <transition-group>. 

  • В отличии от <transition>, этот компонент создаёт реальный элемент. По умолчанию это <span>, но можно изменить на другой, указав атрибут tag.
  • У каждого элемента внутри <transition-group> всегда обязательно должно быть уникальное значение атрибута key.

Анимация вставки и удаления элементов списка

Несложный пример анимации перехода вставки и удаления, использующий CSS-классы рассмотренные ранее:

<div id="list-demo">
  <button v-on:click="add">Добавить</button>
  <button v-on:click="remove">Удалить</button>
  <transition-group name="list" tag="p">
    <span v-for="item in items" v-bind:key="item" class="list-item">
      {{ item }}
    </span>
  </transition-group>
</div>
new Vue({
  el: '#list-demo',
  data: {
    items: [1,2,3,4,5,6,7,8,9],
    nextNum: 10
  },
  methods: {
    randomIndex: function () {
      return Math.floor(Math.random() * this.items.length)
    },
    add: function () {
      this.items.splice(this.randomIndex(), 0, this.nextNum++)
    },
    remove: function () {
      this.items.splice(this.randomIndex(), 1)
    },
  }
})
.list-item {
  display: inline-block;
  margin-right: 10px;
}
.list-enter-active, .list-leave-active {
  transition: all 1s;
}
.list-enter, .list-leave-to /* .list-leave-active до версии 2.1.8 */ {
  opacity: 0;
  transform: translateY(30px);
}

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

Анимация перемещения элементов списка

Компонент <transition-group>  способен анимировать не только появление и удаление элементов, но также и их перемещение. Происходит это путём добавления класса v-move, который указывается при изменении позиции элементов. Как и с другими классами, префикс устанавливается в соответствии с указанным значением атрибута name. Аналогично, можно указать класс вручную в атрибуте move-class.

Этим классом удобнее всего указывать продолжительность и тайминги перехода:

<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.14.1/lodash.min.js"></script>

<div id="flip-list-demo" class="demo">
  <button v-on:click="shuffle">Перемешать</button>
  <transition-group name="flip-list" tag="ul">
    <li v-for="item in items" v-bind:key="item">
      {{ item }}
    </li>
  </transition-group>
</div>
new Vue({
  el: '#flip-list-demo',
  data: {
    items: [1,2,3,4,5,6,7,8,9]
  },
  methods: {
    shuffle: function () {
      this.items = _.shuffle(this.items)
    }
  }
})
.flip-list-move {
  transition: transform 1s;
}

Хотя это и может выглядеть как магия, “под капотом” Vue использует довольно простую анимационную технику под названием FLIP, которая позволяет плавно перевести элементы с их старых позиций на новые, используя CSS-трансформации.
Теперь мы можем совместить эту технику с нашим предыдущим примером, чтобы анимировать все возможные изменения нашего списка!

<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.14.1/lodash.min.js"></script>

<div id="list-complete-demo" class="demo">
  <button v-on:click="shuffle">Перемешать</button>
  <button v-on:click="add">Добавить</button>
  <button v-on:click="remove">Удалить</button>
  <transition-group name="list-complete" tag="p">
    <span
      v-for="item in items"
      v-bind:key="item"
      class="list-complete-item"
    >
      {{ item }}
    </span>
  </transition-group>
</div>
new Vue({
  el: '#list-complete-demo',
  data: {
    items: [1,2,3,4,5,6,7,8,9],
    nextNum: 10
  },
  methods: {
    randomIndex: function () {
      return Math.floor(Math.random() * this.items.length)
    },
    add: function () {
      this.items.splice(this.randomIndex(), 0, this.nextNum++)
    },
    remove: function () {
      this.items.splice(this.randomIndex(), 1)
    },
    shuffle: function () {
      this.items = _.shuffle(this.items)
    }
  }
})
.list-complete-item {
  transition: all 1s;
  display: inline-block;
  margin-right: 10px;
}
.list-complete-enter, .list-complete-leave-to
/* .list-complete-leave-active до версии 2.1.8 */ {
  opacity: 0;
  transform: translateY(30px);
}
.list-complete-leave-active {
  position: absolute;
}

FLIP-анимации не работают с элементами, для которых установлен режим позиционирования display: inline. Вместо него нужно использовать режим display: inline-block или flex-контейнер.

Упругая анимация элементов списка

Настраивая JavaScript-переходы data-атрибутами, возможно также организовать упругую анимацию списка:

<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.2.3/velocity.min.js"></script>

<div id="staggered-list-demo">
  <input v-model="query">
  <transition-group
    name="staggered-fade"
    tag="ul"
    v-bind:css="false"
    v-on:before-enter="beforeEnter"
    v-on:enter="enter"
    v-on:leave="leave"
  >
    <li
      v-for="(item, index) in computedList"
      v-bind:key="item.msg"
      v-bind:data-index="index"
    >{{ item.msg }}</li>
  </transition-group>
</div>
new Vue({
  el: '#staggered-list-demo',
  data: {
    query: '',
    list: [
      { msg: 'Брюс Ли' },
      { msg: 'Джеки Чан' },
      { msg: 'Чак Норрис' },
      { msg: 'Джет Ли' },
      { msg: 'Кунг Фьюри' }
    ]
  },
  computed: {
    computedList: function () {
      var vm = this
      return this.list.filter(function (item) {
        return item.msg.toLowerCase().indexOf(vm.query.toLowerCase()) !== -1
      })
    }
  },
  methods: {
    beforeEnter: function (el) {
      el.style.opacity = 0
      el.style.height = 0
    },
    enter: function (el, done) {
      var delay = el.dataset.index * 150
      setTimeout(function () {
        Velocity(
          el,
          { opacity: 1, height: '1.6em' },
          { complete: done }
        )
      }, delay)
    },
    leave: function (el, done) {
      var delay = el.dataset.index * 150
      setTimeout(function () {
        Velocity(
          el,
          { opacity: 0, height: 0 },
          { complete: done }
        )
      }, delay)
    }
  }
})

Повторное использование анимированных переходов

Анимация переходов может быть переиспользована благодаря компонентной системе Vue. Всё, что необходимо сделать — это поместить компонент <transition> или <transition-group> в корне компонента, а затем передать в этот компонент потомков.
Пример с использованием компонента со строковым шаблоном:

Vue.component('my-special-transition', {
  template: '\
    <transition\
      name="very-special-transition"\
      mode="out-in"\
      v-on:before-enter="beforeEnter"\
      v-on:after-enter="afterEnter"\
    >\
      <slot></slot>\
    </transition>\
  ',
  methods: {
    beforeEnter: function (el) {
      // ...
    },
    afterEnter: function (el) {
      // ...
    }
  }
})

Для этих целей подходят функциональные компоненты:

Vue.component('my-special-transition', {
  functional: true,
  render: function (createElement, context) {
    var data = {
      props: {
        name: 'very-special-transition',
        mode: 'out-in'
      },
      on: {
        beforeEnter: function (el) {
          // ...
        },
        afterEnter: function (el) {
          // ...
        }
      }
    }
    return createElement('transition', data, context.children)
  }
})

Динамические переходы

Даже анимационные переходы во Vue управляются данными. Простейший пример — связывание атрибута name с динамическим свойством.

<transition v-bind:name="transitionName">
  <!-- ... -->
</transition>

Может пригодиться, если вы определили CSS-переходы или анимации, используя принятые во Vue соглашения об именовании классов, и просто хотите переключаться между ними.
На самом деле, любой атрибут может быть динамически связан. И речь не только об атрибутах. Поскольку хуки — это просто методы, у них есть доступ ко всем данным в текущем контексте, а значит и JavaScript-анимации могут зависеть от состояния компонента.
 

<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.2.3/velocity.min.js"></script>

<div id="dynamic-fade-demo" class="demo">
  Fade In: <input type="range" v-model="fadeInDuration" min="0" v-bind:max="maxFadeDuration">
  Fade Out: <input type="range" v-model="fadeOutDuration" min="0" v-bind:max="maxFadeDuration">
  <transition
    v-bind:css="false"
    v-on:before-enter="beforeEnter"
    v-on:enter="enter"
    v-on:leave="leave"
  >
    <p v-if="show">hello</p>
  </transition>
  <button
    v-if="stop"
    v-on:click="stop = false; show = false"
  >Запустить анимацию</button>
  <button
    v-else
    v-on:click="stop = true"
  >Остановить анимацию</button>
</div>
new Vue({
  el: '#dynamic-fade-demo',
  data: {
    show: true,
    fadeInDuration: 1000,
    fadeOutDuration: 1000,
    maxFadeDuration: 1500,
    stop: true
  },
  mounted: function () {
    this.show = false
  },
  methods: {
    beforeEnter: function (el) {
      el.style.opacity = 0
    },
    enter: function (el, done) {
      var vm = this
      Velocity(el,
        { opacity: 1 },
        {
          duration: this.fadeInDuration,
          complete: function () {
            done()
            if (!vm.stop) vm.show = false
          }
        }
      )
    },
    leave: function (el, done) {
      var vm = this
      Velocity(el,
        { opacity: 0 },
        {
          duration: this.fadeOutDuration,
          complete: function () {
            done()
            vm.show = true
          }
        }
      )
    }
  }
})