Анимация в Svelte

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

Модули

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

  • анимировать
  • смягчение
  • движение
  • переход

svelte/easing

Этот пакет содержит ряд функций с уравнениями для создания различных кривых смягчения.
Доступны следующие кривые:

  • назад
  • отскок
  • круг
  • кубическая
  • эластичный
  • экспозиция
  • квад
  • кварта
  • квинта
  • синус

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

svelte/motion

В этом пакете экспортируются две функции: tweened и spring.

Обе они возвращают реактивное значение, интерполируя промежуточные значения, заданные набором параметров.

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

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

tweened

Давайте инициализируем новое приложение Svelte, чтобы посмотреть, как оно работает.

npm init vite

✔ Project name: · svelte-animations
✔ Select a framework: · svelte
✔ Select a variant: · svelte-ts

cd svelte-web-components
pnpm install //use the package manager you prefer
pnpm run dev

// remove default Counter component
rm src/lib/Counter.svelte
Вход в полноэкранный режим Выйти из полноэкранного режима

Очистите компонент App.svelte, чтобы он содержал только то, что нам сейчас нужно.

<script>
    // add imports here
</script>

<main>
</main>

<style>
  :root {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
      Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
  }

  main {
    text-align: center;
    padding: 1em;
    margin: 0 auto;
  }

  :global(main > * + *)  {
    margin-top: 24px;
  }
</style>
Вход в полноэкранный режим Выход из полноэкранного режима

Я создам новый компонент Tasks.svelte в папке lib.

<script lang="ts">
  import { tweened } from 'svelte/motion';
  export let tasks: { id; title; date }[] = [];

  let selected;
  tasks = tasks.sort((a, b) => {
    if (a.date > b.date) {
      return 1;
    } else if (a.date === b.date) {
      return 0;
    } else {
      return -1;
    }
  });

  function pad(num) {
    if (num < 10) {
      return `0${num}`;
    }
    return num;
  }

  function getDate(date) {
    return date
      ? `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(
          date.getDate(),
        )}`
      : '';
  }

  function getTime(date) {
    return date ? `${pad(date.getHours())}:${pad(date.getMinutes())}` : '';
  }

  let now = new Date();
  let date = tweened(now, { duration: 500 });

  function selectTask(task) {
    selected = task.id;
    date.set(task.date);
  }
</script>

<div class="task-view">
  <div class="task-list">
    <h2>Next tasks</h2>
    <ul>
      {#each tasks as task}
        <li
          class={selected === task.id ? 'selected' : ''}
          on:click={() => selectTask(task)}
        >
          {task.title}
        </li>
      {/each}
    </ul>
  </div>
  <div class="task-details">
    <h2>When?</h2>
    {#if selected}
      <p>{getDate($date)}</p>
      <p>{getTime($date)}</p>
    {/if}
  </div>
</div>

<style>
  .task-view {
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    width: 300px;
    border: 2px solid #4f4f4f;
    border-radius: 8px;
    padding: 16px;
  }
  li {
    padding: 4px 8px;
  }
  li.selected {
    background-color: lightcyan;
  }
  li:hover {
    background-color: lightgray;
  }
</style>

Вход в полноэкранный режим Выйти из полноэкранного режима

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

Давайте обновим приложение, чтобы использовать этот компонент.

<script lang="ts">
  import Tasks from './lib/Tasks.svelte';

  let tasks = [
    { id: 1, title: 'Meeting', date: new Date('2021-12-17T03:24:00') },
    { id: 2, title: 'Gym', date: new Date('2021-08-22T09:12:00') },
    { id: 3, title: 'Movie', date: new Date('2021-09-01T22:07:00') },
  ];
</script>

<main>
  <Tasks {tasks} />
</main>
<!-- ... -->
Вход в полноэкранный режим Выход из полноэкранного режима

Результат выглядит следующим образом:

В этом примере мы анимируем значение, но мы также можем применить эти изменения к свойствам CSS.

Давайте создадим другой пример, в котором это достигается. (Tweened.svelte).

<script>
  import { tweened } from 'svelte/motion';
  import { cubicOut } from 'svelte/easing';

  const toColor = tweened([255, 0, 0], {
    duration: 2000,
    easing: cubicOut,
  });

  let loop = () =>
    toColor
      .set([255, 0, 0])
      .then(() => toColor.set([0, 255, 0]))
      .then(() => toColor.set([0, 0, 255]))
      .then(() => loop());
  loop();
</script>

<div style={'background-color:rgb(' + $toColor.join(',') + ')'} />

<style>
  div {
    display: block;
    width: 100px;
    height: 100px;
  }
</style>

Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы создали один div и используем tweened для интерполяции значений массива.

Когда мы устанавливаем значение с помощью функции set, она возвращает обещание, которое разрешается при достижении конечного значения (для наших целей анимация закончилась). Затем мы снова вызываем новое значение с помощью set. Мы видим в действии, как мы можем интерполировать значения массива.

Мы не должны забывать обновлять наше приложение

<script lang="ts">
// ...
  import Tweened from './lib/Tweened.svelte';
// ...
</script>

<main>
  <!-- ... -->
  <Tweened />
</main>
Войдите в полноэкранный режим Выход из полноэкранного режима

Возможными параметрами для tweened являются: delay (время до начала), duration (в миллисекундах), easing (одна из функций смягчения, показанных ранее), interpolate (функция (from, to) => t => value).

Spring

Spring работает по-другому для перехода переменной от одного значения к другому. Мы можем задать три параметра: stiffness, damping, которые будут задавать поведение пружины при установлении конечного значения, и precision, который будет определять, когда значение считается установленным.

Давайте создадим новый компонент с именем Spring.svelte.

<script>
    import { spring } from 'svelte/motion';

    const number = spring(0,{
    stiffness: 0.1,
    damping: 0.08
});

function changeValueTo(newValue) {
    number.set(newValue)
}

function resetValue() {
    number.set(0, {hard:true})
}

</script>

<div>
    <span>{$number.toFixed(1)}</span>
    <button on:click={() => changeValueTo(10)}>To 10</button>
    <button on:click={() => changeValueTo(100)}>To 100</button>
    <button on:click={() => changeValueTo(1000)}>To 1000</button>
    <button  on:click={() => resetValue()}>Reset</button>
</div>

<style>
    div {
        display: flex;
        flex-direction:column;
        max-width:300px;
    }
</style>
Вход в полноэкранный режим Выход из полноэкранного режима

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

Нам нужно обновить наше приложение, чтобы импортировать компонент.

<script lang="ts">
// ...
  import Spring from './lib/Spring.svelte';
// ...
</script>

<main>
  <!-- ... -->
  <Spring />
</main>
Вход в полноэкранный режим Выход из полноэкранного режима

Вот как выглядит конечный результат.

svelte/transition

Переход — это функция со следующей сигнатурой:

(node: HTMLElement, params: any) => {
    delay?: number,
    duration?: number,
    easing?: (t: number) => number,
    css?: (t: number, u: number) => string,
    tick?: (t: number, u: number) => void
}
Вход в полноэкранный режим Выход из полноэкранного режима

Модуль svelte/transition включает в себя ряд функций, которые позволяют нам анимировать наш DOM: blur, draw, fade, fly, scale, slide и crossfade (последняя функция возвращает две функции перехода).

Они используются с директивами transition, in или out.
Переход выполняется, когда элемент входит или выходит из DOM. Для этой директивы доступны четыре события introstart, introend, outrostart, outroend они запускаются всякий раз, когда начинается и заканчивается начальная или конечная анимация.

Директивы in и out работают как transition, но они срабатывают только при добавлении или удалении элемента.

Создайте новый компонент с именем Transition.svelte.

<script lang="ts">
  import { onDestroy, onMount } from 'svelte';
  import {
    blur,
    crossfade,
    draw,
    fade,
    fly,
    scale,
    slide,
  } from 'svelte/transition';

  let show = false;
  let interval;

  let [from, to] = crossfade({
    fallback: () => {
      return { css: (t, u) => 'color:red' };
    },
  });

  onMount(() => {
    interval = setInterval(() => {
      show = !show;
    }, 2000);
  })

  onDestroy(() => {
    if (interval) {
      clearInterval(interval);
    }  
  });
</script>

<div class="playground">
  <div class="transition-item">
    <svg
      fill="#ffffff"
      width="32"
      height="32"
      viewBox="0 0 16 16"
      xmlns="http://www.w3.org/2000/svg"
    >
      {#if show}
        <path
          in:draw={{ duration: 1500 }}
          d="M1.414213562373095 0 16 14.585786437626904 L14.585786437626904 16 L0 1.414213562373095"
        />
        <path
          in:draw={{ duration: 1500 }}
          d="M14.585786437626904 0 L16 1.414213562373095 L1.414213562373095 16 L0 14.585786437626904"
        />
      {/if}
    </svg>
  </div>
  <div class="transition-item teleport">
    <div>
      {#if show}
        <span in:from={{ key: 'a' }} out:to={{ key: 'a' }}>cross...</span>
      {/if}
    </div>
    <div>
      {#if !show}
        <span in:from={{ key: 'a' }} out:to={{ key: 'a' }}>...fade</span>
      {/if}
    </div>
  </div>

  {#if show}
    <div class="transition-item" transition:blur>
      <span>Blur</span>
    </div>
    <div class="transition-item" transition:fade>
      <span>Fade</span>
    </div>
    <div class="transition-item" transition:fly={{ x: 30 }}>
      <span>Fly</span>
    </div>
    <div class="transition-item" transition:scale={{ start: 10 }}>
      <span>Scale</span>
    </div>
    <div class="transition-item" transition:slide>
      <span>Slide</span>
    </div>
  {/if}
</div>

<style>
  .teleport {
    display: flex;
    flex-direction: row;
    justify-content: center;
    width: 200px;
    margin-left:auto;
    margin-right:auto;
    border: 2px solid #4f4f4f;
    border-radius: 8px;
    padding: 16px;

  }
  .teleport > div {
      width: 100px;
    }

  svg {
    height: 128px;
    width: 128px;
  }

  path {
    stroke: black;
  }

  .transition-item + .transition-item {
    margin-top: 40px;
  }
</style>
Войдите в полноэкранный режим Выйти из полноэкранного режима

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

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

Мы можем создавать пользовательские переходы, создав функцию, которая принимает HTML-элемент, объект конфигурации и возвращает объект с необходимыми свойствами.

Мы создадим новую функцию под названием skew.

export function skew(node: HTMLElement, {delay = 0, duration = 1000, easing = cubicInOut, deg = 45} = {}) {
        const style = getComputedStyle(node);
        const target_opacity = +style.opacity;
        const transform = style.transform === 'none' ? '' : style.transform;
        return {
            delay,
            duration,
            easing,
            css: (_t, u) => `
                transform: ${transform} skew(${deg * u}deg);
                opacity: ${target_opacity * _t}
            `
        };
}
Вход в полноэкранный режим Выход из полноэкранного режима

delay, duration и easing являются довольно стандартными для всех поставляемых функций, поэтому мы оставим их такими же для простоты использования. Волшебство происходит в нашем свойстве css. Основываясь на наших параметрах, мы добавим преобразование перекоса. u — это не что иное, как 1-_t, поэтому в данном случае мы будем начинать с deg (перекос применяется) до 0 (перекос отсутствует), когда элемент будет показан.
При удалении произойдет обратное.

Давайте проверим это, создав новый компонент. (Skew.svelte)

<script lang="ts">
  import { onDestroy, onMount } from 'svelte';
  import { skew } from './skew';

  export let skewOptions = {};

  let show = false;
  let interval;

  onMount(() => {
    interval = setInterval(() => {
      show = !show;
    }, 2000);
  });

  onDestroy(() => {
    if (interval) {
      clearInterval(interval);
    }
  });
</script>

<div class="playground">
  {#if show}
    <div class="transition-item" transition:skew={skewOptions}>
      <span>Skew</span>
    </div>
  {/if}
</div>
Войти в полноэкранный режим Выйти из полноэкранного режима

svelte/animate

Этот пакет экспортирует единственную функцию: flip.
Анимация должна использоваться с помощью директивы animate.

Обратите внимание, что существует требование для использования этой директивы.
Элемент, использующий директиву animate, должен быть непосредственным дочерним элементом блока keyed each.

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

Сигнатура анимации следующая:

(node: HTMLElement, { from: DOMRect, to: DOMRect } , params: any) => {
    delay?: number,
    duration?: number,
    easing?: (t: number) => number,
    css?: (t: number, u: number) => string,
    tick?: (t: number, u: number) => void
}
Вход в полноэкранный режим Выход из полноэкранного режима

Как видите, сигнатура очень похожа на сигнатуру переходов. Мы воспользуемся этим сходством позже.

Создайте новый компонент, чтобы проверить, что делают flip и директива animate.

<!-- Flip.svelte -->

<script lang="ts">
  import { flip } from 'svelte/animate';
  let things = [
    { id: 1, name: 'foo', ready: true },
    { id: 2, name: 'bar', ready: false },
    { id: 3, name: 'baz', ready: true },
    { id: 4, name: 'fizz', ready: false },
  ];
  let sortBy = { field: 'id', order: 'DESC' };

  let sortedThings = things;

  function sortById() {
    if (
      sortBy.field !== 'id' ||
      (sortBy.field === 'id' && sortBy.order === 'DESC')
    ) {
      sortedThings = things.sort((a, b) => {
        if (a.id > b.id) {
          return 1;
        } else if (a.id < b.id) {
          return -1;
        }
        return 0;
      });
      sortBy = { field: 'id', order: 'ASC' };
    } else {
      sortedThings = things.sort((a, b) => {
        if (a.id > b.id) {
          return -1;
        } else if (a.id < b.id) {
          return 1;
        }
        return 0;
      });
      sortBy = { field: 'id', order: 'DESC' };
    }
  }
  function sortByName() {
    if (
      sortBy.field !== 'name' ||
      (sortBy.field === 'name' && sortBy.order === 'DESC')
    ) {
      sortedThings = things.sort((a, b) => {
        if (a.name > b.name) {
          return 1;
        } else if (a.name < b.name) {
          return -1;
        }
        return 0;
      });
      sortBy = { field: 'name', order: 'ASC' };
    } else {
      sortedThings = things.sort((a, b) => {
        if (a.name > b.name) {
          return -1;
        } else if (a.name < b.name) {
          return 1;
        }
        return 0;
      });
      sortBy = { field: 'name', order: 'DESC' };
    }
  }
  function sortByReadyState() {
    if (
      sortBy.field !== 'ready' ||
      (sortBy.field === 'ready' && sortBy.order === 'DESC')
    ) {
      sortedThings = [
        ...sortedThings.filter((x) => x.ready),
        ...sortedThings.filter((x) => !x.ready),
      ];
      sortBy = { field: 'ready', order: 'ASC' };
    } else {
      sortedThings = [
        ...sortedThings.filter((x) => !x.ready),
        ...sortedThings.filter((x) => x.ready),
      ];
      sortBy = { field: 'ready', order: 'DESC' };
    }
  }
</script>

<div class="container">
  <table>
    <tr>
      <th on:click={sortById}>id</th>
      <th on:click={sortByName}>name</th>
      <th on:click={sortByReadyState}>ready</th>
    </tr>
    {#each sortedThings as thing (thing.id)}
      <tr animate:flip>
        <td>{thing.id}</td>
        <td>
          {thing.name}
        </td>
        <td><input type="checkbox" bind:checked={thing.ready} /></td>
      </tr>
    {/each}
  </table>
</div>

<style>
  td {
    width: 100px;
  }
  .container {
    width: 100vw;
    display: flex;
    flex-direction: row;
  }
  table,
  tr,
  td,
  th {
    border: 1px solid gray;
    border-collapse: collapse;
  }
  th {
    cursor: pointer;
  }
</style>
Вход в полноэкранный режим Выход из полноэкранного режима

Мы создали таблицу с 4 строками и возможностью упорядочивать строки по различным свойствам.

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

Результат выглядит следующим образом.

расширение и повторное использование анимации с помощью переходов

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

анимации из переходов

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

export function toAnimation<T>(
  fn: (node: HTMLElement, params) => T,
): (node: HTMLElement, { from, to }, params) => T {
  return (node, _animations, params = {}) => {
    return fn(node, params);
  };
}
Вход в полноэкранный режим Выход из полноэкранного режима

Затем мы можем преобразовать один из наших переходов и применить его с помощью директивы animate.

<!--AnimationFromTransition.svelte -->
<script>
  import { fade } from 'svelte/transition';
  import { toAnimation } from './toAnimation';

  let fadeAnimation = toAnimation(fade);

 // ... same as Flip.svelte
</script>

<div class="container">
  <table>
    <tr>
      <th on:click={sortById}>id</th>
      <th on:click={sortByName}>name</th>
      <th on:click={sortByReadyState}>ready</th>
    </tr>
    {#each sortedThings as thing (thing.id)}
      <tr animate:fadeAnimation={{ duration: 400 }}>
        <td>{thing.id}</td>
        <td>
          {thing.name}
        </td>
        <td><input type="checkbox" bind:checked={thing.ready} /></td>
      </tr>
    {/each}
  </table>
</div>

<style>
  /* same as Flip.svelte*/
</style>
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь, вместо перемещения, переупорядоченные элементы исчезают/затухают.

Расширение перелистывания

Мы также можем расширить анимацию перелистывания с помощью переходов. Я снова создам функцию-обертку.

// extendFlip.ts

import { flip } from 'svelte/animate';
export function extendFlip(fn) {
  return (node, animations, params = {}) => {
    let flipRes = flip(node, animations, params);
    let transitionRes = fn(node, params);

    let getTransform = (str) => {
      let results = str.match(/transform: (.*);/);
      if (results && results.length) {
        return results[results.length - 1];
      }
      return '';
    };

    let mergeTransform = (css1, css2) => {
      return `transform: ${getTransform(css1)} ${getTransform(css2)};`;
    };

    return {
      ...flipRes,
      css: (t, u) =>
        `${transitionRes.css(t, u)}; ${mergeTransform(
          flipRes.css(t, u),
          transitionRes.css(t, u),
        )};`,
    };
  };
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Наша функция получит функцию перехода и объединит свойство transform, которое она возвращает, со свойством flip.

Теперь рассмотрим немного измененную версию предыдущего компонента:

<script>
  import { scale, blur } from 'svelte/transition';
  import { extendFlip } from './extendFlip';

  let flipAndBlur = extendFlip(blur);
  let flipAndScale = extendFlip(blur);

  let things = [
    { id: 1, name: 'foo', ready: true },
    { id: 2, name: 'bar', ready: false },
    { id: 3, name: 'baz', ready: true },
    { id: 4, name: 'fizz', ready: false },
  ];

  let sortBy = { field: 'id', order: 'DESC' };

  let sortedThings = things;

  function sortById() {
    if (
      sortBy.field !== 'id' ||
      (sortBy.field === 'id' && sortBy.order === 'DESC')
    ) {
      sortedThings = things.sort((a, b) => {
        if (a.id > b.id) {
          return 1;
        } else if (a.id < b.id) {
          return -1;
        }
        return 0;
      });
      sortBy = { field: 'id', order: 'ASC' };
    } else {
      sortedThings = things.sort((a, b) => {
        if (a.id > b.id) {
          return -1;
        } else if (a.id < b.id) {
          return 1;
        }
        return 0;
      });
      sortBy = { field: 'id', order: 'DESC' };
    }
  }
  function sortByName() {
    if (
      sortBy.field !== 'name' ||
      (sortBy.field === 'name' && sortBy.order === 'DESC')
    ) {
      sortedThings = things.sort((a, b) => {
        if (a.name > b.name) {
          return 1;
        } else if (a.name < b.name) {
          return -1;
        }
        return 0;
      });
      sortBy = { field: 'name', order: 'ASC' };
    } else {
      sortedThings = things.sort((a, b) => {
        if (a.name > b.name) {
          return -1;
        } else if (a.name < b.name) {
          return 1;
        }
        return 0;
      });
      sortBy = { field: 'name', order: 'DESC' };
    }
  }
  function sortByReadyState() {
    if (
      sortBy.field !== 'ready' ||
      (sortBy.field === 'ready' && sortBy.order === 'DESC')
    ) {
      sortedThings = [
        ...sortedThings.filter((x) => x.ready),
        ...sortedThings.filter((x) => !x.ready),
      ];
      sortBy = { field: 'ready', order: 'ASC' };
    } else {
      sortedThings = [
        ...sortedThings.filter((x) => !x.ready),
        ...sortedThings.filter((x) => x.ready),
      ];
      sortBy = { field: 'ready', order: 'DESC' };
    }
  }
</script>

<div class="container">
  <table>
    <tr>
      <th on:click={sortById}>id</th>
      <th on:click={sortByName}>name</th>
      <th on:click={sortByReadyState}>ready</th>
    </tr>
    {#each sortedThings as thing (thing.id)}
      <tr animate:flipAndBlur>
        <td>{thing.id}</td>
        <td>
          {thing.name}
        </td>
        <td><input type="checkbox" bind:checked={thing.ready} /></td>
      </tr>
    {/each}
  </table>
</div>

<style>
  td {
    width: 100px;
  }
  .container {
    width: 100vw;
    display: flex;
    flex-direction: row;
  }
  table,
  tr,
  td,
  th {
    border: 1px solid gray;
    border-collapse: collapse;
  }
  th {
    cursor: pointer;
  }
</style>

Вход в полноэкранный режим Выход из полноэкранного режима

И результаты:

Размытие + переворот

Масштаб + флип

Заключительные слова

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

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


This Dot Labs — это современная веб-консалтинговая компания, которая помогает компаниям реализовать их усилия по цифровой трансформации. Для получения экспертного архитектурного руководства, обучения или консультаций по React, Angular, Vue, Web Components, GraphQL, Node, Bazel или Polymer посетите thisdotlabs.com.

This Dot Media ориентирована на создание инклюзивного и образовательного интернета для всех. Мы информируем вас о достижениях современного веба с помощью мероприятий, подкастов и бесплатного контента. Чтобы узнать больше, посетите сайт thisdot.co.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *