Анимация динамических списков

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


Ключевая техника, применяемая для динамических интерфейсов, — FLIP (First, Last, Invert, Play). Она позволяет анимировать любое изменение геометрии элементов без привязки к конкретной анимационной библиотеке, однако в связке с Popmotion становится особенно гибкой.

FLIP состоит из четырёх этапов:

  • First — фиксация начальных координат элементов
  • Last — применение изменений в DOM
  • Invert — вычисление разницы между старым и новым положением
  • Play — анимация трансформации к финальному состоянию

Снятие начальных координат

Перед любыми изменениями списка необходимо сохранить текущее положение элементов. Для этого используется getBoundingClientRect.

const items = document.querySelectorAll('.item');

const firstPositions = new Map();

items.forEach(item => {
  const id = item.dataset.id;
  firstPositions.set(id, item.getBoundingClientRect());
});

На этом этапе фиксируется визуальное состояние, независимое от данных, которые будут изменены дальше.


Изменение структуры списка

После фиксации координат выполняется обновление DOM: добавление, удаление или сортировка элементов.

function renderList(data) {
  const container = document.querySelector('.list');
  container.innerHTML = '';

  data.forEach(item => {
    const el = document.createElement('div');
    el.className = 'item';
    el.dataset.id = item.id;
    el.textContent = item.title;
    container.appendChild(el);
  });
}

Этот этап вызывает мгновенный перерасчёт layout, поэтому визуально элементы «прыгают» в новые позиции.


Снятие новых координат

После обновления DOM необходимо снова измерить элементы.

const itemsAfter = document.querySelectorAll('.item');

const lastPositions = new Map();

itemsAfter.forEach(item => {
  const id = item.dataset.id;
  lastPositions.set(id, item.getBoundingClientRect());
});

Вычисление смещения

Разница между старым и новым положением определяет параметры анимации.

const deltas = new Map();

lastPositions.forEach((lastRect, id) => {
  const firstRect = firstPositions.get(id);

  if (!firstRect) return;

  deltas.set(id, {
    x: firstRect.left - lastRect.left,
    y: firstRect.top - lastRect.top
  });
});

Эти значения задают начальную трансформацию для анимации.


Интеграция с Popmotion styler

Popmotion предоставляет styler, позволяющий управлять стилями как анимируемыми значениями.

import { styler, tween, easing } from 'popmotion';

const itemStylers = new Map();

document.querySelectorAll('.item').forEach(item => {
  itemStylers.set(item.dataset.id, styler(item));
});

Каждый элемент получает собственный контроллер стилей.


Инверсия позиции

Перед запуском анимации элементы мгновенно перемещаются в исходное визуальное состояние с помощью transform.

deltas.forEach((delta, id) => {
  const itemStyler = itemStylers.get(id);

  itemStyler.set({
    x: delta.x,
    y: delta.y
  });
});

На этом этапе элементы выглядят так, будто не меняли позицию.


Анимация возврата в финальное состояние

Popmotion используется для плавного перехода к нулевому смещению.

import { animate } from 'popmotion';

deltas.forEach((_, id) => {
  const itemStyler = itemStylers.get(id);

  animate({
    from: { x: itemStyler.get('x'), y: itemStyler.get('y') },
    to: { x: 0, y: 0 },
    duration: 500,
    ease: easing.cubicBezier(0.2, 0.8, 0.2, 1),
    onUpdate: v => itemStyler.set(v)
  });
});

Такой подход создаёт эффект плавного перемещения элементов между позициями.


Анимация добавления элементов

Новые элементы не участвуют в FLIP-вычислении, поэтому требуют отдельного подхода. Обычно применяется стартовая трансформация и последующий tween.

import { spring } from 'popmotion';

const newItem = document.querySelector('.item.new');
const newStyler = styler(newItem);

newStyler.set({
  opacity: 0,
  scale: 0.8
});

spring({
  from: { opacity: 0, scale: 0.8 },
  to: { opacity: 1, scale: 1 },
  stiffness: 200,
  damping: 20
}).start(v => newStyler.set(v));

Использование spring-модели делает появление элементов более естественным.


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

Удаление требует сохранения элемента в DOM до завершения анимации.

function removeItem(el) {
  const s = styler(el);

  tween({
    from: { opacity: 1, scale: 1 },
    to: { opacity: 0, scale: 0.8 },
    duration: 300
  }).start({
    update: v => s.set(v),
    complete: () => el.remove()
  });
}

Перестановка элементов

При сортировке списка FLIP-алгоритм становится основным механизмом. Важный момент — идентификаторы элементов должны оставаться стабильными.

function reorder(data) {
  const first = capturePositions();

  renderList(data);

  const last = capturePositions();

  applyFlip(first, last);
}

Комбинирование с stagger-анимацией

Popmotion позволяет создавать каскадные эффекты через последовательный запуск анимаций.

import { stagger } from 'popmotion';

const items = document.querySelectorAll('.item');

stagger(items, {
  start: { opacity: 0, y: 20 },
  end: { opacity: 1, y: 0 },
  duration: 400,
  delay: 50
});

Это усиливает визуальную иерархию при перерисовке списков.


Синхронизация состояния и анимации

Основная проблема динамических списков — расхождение между данными и DOM. Popmotion не управляет состоянием напрямую, поэтому требуется явное разделение:

  • данные списка — источник истины
  • DOM — производное представление
  • анимации — слой визуального перехода

Такой подход предотвращает конфликт между перерендером и активной анимацией.


Работа с transform-матрицей

Для повышения производительности используется только transform, без изменения layout-свойств.

styler.set({
  transform: `
    translateX(${x}px)
    translateY(${y}px)
  `
});

Это позволяет избежать forced reflow и сохранить 60 FPS при больших списках.


Оптимизация большого количества элементов

При работе с сотнями элементов применяется несколько стратегий:

  • виртуализация списка
  • переиспользование styler-объектов
  • отказ от частых измерений DOM
  • группировка анимаций в один кадр

Использование value-объектов Popmotion

value позволяет хранить анимируемые состояния вне DOM и синхронизировать их при необходимости.

import { value } from 'popmotion';

const x = value(0, v => {
  el.style.transform = `translateX(${v}px)`;
});

x.set(100);

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


Координация нескольких анимаций

Сложные списки требуют одновременного управления несколькими типами анимаций:

  • перемещение (FLIP)
  • появление (opacity + scale)
  • удаление (fade out)
  • перестановка (spring movement)

Popmotion позволяет запускать их параллельно без конфликта состояний.

animate({ from: 0, to: 1, onUpdate: v => opacity.set(v) });
spring({ from: y0, to: y1, onUpdate: v => y.set(v) });
tween({ from: s0, to: s1, onUpdate: v => scale.set(v) });

Обработка прерывания анимаций

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

let currentAnimation;

function runAnimation(config) {
  if (currentAnimation) currentAnimation.stop();

  currentAnimation = animate(config);
}

Это предотвращает накопление анимационных процессов.


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

При потоковых обновлениях данных применяется batching:

  • изменения собираются в очередь
  • применяется один render
  • выполняется один FLIP-цикл
  • запускаются сгруппированные анимации

Такой подход критичен для интерфейсов реального времени.


Смешанные трансформации

Popmotion позволяет объединять физические модели движения с FLIP-анимацией. Например, spring используется для имитации инерции при перемещении карточек.

spring({
  from: delta,
  to: 0,
  stiffness: 180,
  damping: 25,
  mass: 1
}).start(v => styler.set({ x: v }));

Работа с высотой элементов

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

const firstHeight = el.getBoundingClientRect().height;

el.classList.add('expanded');

const lastHeight = el.getBoundingClientRect().height;

animate({
  from: firstHeight,
  to: lastHeight,
  onUpdate: h => el.style.height = `${h}px`
});

Композиция анимационных слоёв

В сложных интерфейсах динамический список разбивается на слои:

  • слой данных
  • слой layout (FLIP)
  • слой микровзаимодействий (hover, focus)
  • слой переходов (mount/unmount)

Popmotion используется преимущественно на слоях layout и transition, обеспечивая независимость анимаций от бизнес-логики.