← Курс/Transitions и анимации#239 из 257+30 XP

Transitions и анимации в Vue

Компонент Transition

Vue предоставляет компонент <Transition> для анимации появления и исчезновения одного элемента:

<template>
  <button @click="show = !show">Переключить</button>

  <Transition name="fade">
    <p v-if="show">Этот элемент анимируется</p>
  </Transition>
</template>
/* Классы автоматически добавляются/убираются Vue */

/* Начало появления / конец исчезновения */
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
  transform: translateY(-10px);
}

/* Во время появления */
.fade-enter-active {
  transition: all 0.3s ease-out;
}

/* Во время исчезновения */
.fade-leave-active {
  transition: all 0.2s ease-in;
}

/* Финальное состояние появления / начало исчезновения */
.fade-enter-to,
.fade-leave-from {
  opacity: 1;
  transform: translateY(0);
}

CSS-классы переходов

Временная шкала для enter-перехода:

v-enter-from  ------>  v-enter-active  ------>  v-enter-to
(начало)              (весь процесс)            (конец)

Для leave-перехода:

v-leave-from  ------>  v-leave-active  ------>  v-leave-to
(начало)              (весь процесс)            (конец)

JavaScript-хуки

Для сложных анимаций (GSAP, Anime.js) используют JS-хуки:

<Transition
  @before-enter="onBeforeEnter"
  @enter="onEnter"
  @after-enter="onAfterEnter"
  @enter-cancelled="onEnterCancelled"
  @before-leave="onBeforeLeave"
  @leave="onLeave"
  @after-leave="onAfterLeave"
  :css="false"
>
  <div v-if="show">Контент</div>
</Transition>
function onEnter(el, done) {
  // done() нужно вызвать, когда анимация завершена
  gsap.from(el, {
    opacity: 0,
    y: -20,
    duration: 0.3,
    onComplete: done
  })
}

TransitionGroup

Для анимации списков используют <TransitionGroup>:

<TransitionGroup name="list" tag="ul">
  <li v-for="item in items" :key="item.id">
    {{ item.text }}
  </li>
</TransitionGroup>
.list-move,           /* анимация перемещения (FLIP) */
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* Убираем из потока для корректного FLIP */
.list-leave-active {
  position: absolute;
}

FLIP-техника

**FLIP** (First, Last, Invert, Play) — алгоритм для производительных анимаций перемещения:

1. **First** — запомни позицию элемента ДО изменения

2. **Last** — примени изменение, запомни позицию ПОСЛЕ

3. **Invert** — примени transform, чтобы элемент выглядел как ДО изменения

4. **Play** — анимируй к нулевому transform (к позиции ПОСЛЕ)

Ключевая идея: мы анимируем transform (GPU-ускорение) вместо top/left (перерасчёт layout).

function flip(element) {
  // First
  const first = element.getBoundingClientRect()

  // Применяем изменение (sort, reorder и т.д.)
  doChange()

  // Last
  const last = element.getBoundingClientRect()

  // Invert
  const dx = first.left - last.left
  const dy = first.top - last.top

  element.style.transform = `translate(${dx}px, ${dy}px)`
  element.style.transition = 'none'

  // Play (в следующем кадре)
  requestAnimationFrame(() => {
    element.style.transition = 'transform 0.3s ease'
    element.style.transform = ''
  })
}

Режим appear

<!-- Анимировать элементы при первом рендере -->
<Transition appear name="fade">
  <div>Сразу анимируется при загрузке страницы</div>
</Transition>

Примеры

Реализация FLIP-анимации — техника, которую Vue использует в TransitionGroup для анимации перемещения элементов

// FLIP (First, Last, Invert, Play) — алгоритм для плавных анимаций
// без дорогостоящих перерасчётов layout.
// Используем только transform (GPU) вместо top/left (CPU).

function createFlipAnimator(container) {
  let snapshots = new Map()

  return {
    // Шаг 1: FIRST — снять "снимок" позиций до изменения
    snapshot() {
      snapshots.clear()
      const children = container.children || []
      for (const child of children) {
        if (child.getBoundingClientRect) {
          snapshots.set(child, child.getBoundingClientRect())
        }
      }
      console.log(`[FLIP] snapshot: ${snapshots.size} элементов`)
    },

    // Шаги 2-4: LAST + INVERT + PLAY — применить изменение и анимировать
    animate(duration = 300) {
      const children = container.children || []
      const animations = []

      for (const child of children) {
        const first = snapshots.get(child)
        if (!first || !child.getBoundingClientRect) continue

        // LAST — позиция после изменения
        const last = child.getBoundingClientRect()

        const dx = first.left - last.left
        const dy = first.top - last.top

        if (dx === 0 && dy === 0) continue  // элемент не переместился

        // INVERT — "откат" к старой позиции через transform
        child.style.transform = `translate(${dx}px, ${dy}px)`
        child.style.transition = 'none'

        // PLAY — анимируем к нулю (реальной позиции)
        animations.push(new Promise(resolve => {
          requestAnimationFrame(() => {
            requestAnimationFrame(() => {
              child.style.transition = `transform ${duration}ms cubic-bezier(0.4, 0, 0.2, 1)`
              child.style.transform = ''

              child.addEventListener('transitionend', () => {
                child.style.transition = ''
                resolve()
              }, { once: true })
            })
          })
        }))

        console.log(`[FLIP] ${child.id || 'el'}: dx=${dx.toFixed(1)} dy=${dy.toFixed(1)}`)
      }

      return Promise.all(animations)
    }
  }
}

// --- Симуляция FLIP без реального браузера ---

// Создаём фейковые элементы с позициями
function makeEl(id, x, y, width = 100, height = 40) {
  return {
    id,
    _pos: { left: x, top: y, right: x + width, bottom: y + height, width, height },
    style: {},
    getBoundingClientRect() { return this._pos },
    addEventListener(ev, fn, opts) {
      // Симулируем завершение перехода немедленно
      setTimeout(fn, 0)
    }
  }
}

const items = [
  makeEl('item-A', 0, 0),
  makeEl('item-B', 0, 50),
  makeEl('item-C', 0, 100),
]

const container = { children: items }
const animator = createFlipAnimator(container)

console.log('=== Начальное состояние ===')
console.log('A:', items[0]._pos.top, 'B:', items[1]._pos.top, 'C:', items[2]._pos.top)

// Шаг 1: снять снимок ДО изменения
animator.snapshot()

// Шаг 2: применить изменение (сортировка — меняем позиции)
console.log('\n=== Применяем сортировку (C, A, B) ===')
items[0]._pos.top = 50   // A переместился вниз
items[1]._pos.top = 100  // B переместился ещё ниже
items[2]._pos.top = 0    // C переместился наверх

// Шаги 3-4: INVERT + PLAY
console.log('\n=== FLIP анимация ===')
animator.animate(300).then(() => {
  console.log('\n=== Анимация завершена ===')
  console.log('Финальные transform (должны быть пустыми):')
  items.forEach(el => console.log(`  ${el.id}: "${el.style.transform || '(сброшен)'}"`))
})