Анимация как никогда раньше присутствует на наших веб-сайтах и в приложениях. Они могут заставить их выглядеть и чувствовать себя по-другому, если они сделаны правильно, привлекая ваших пользователей.
В этом посте мы узнаем, как анимации могут быть использованы в 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.