Анимация при наведении – прекрасный способ сделать приложение динамичным и отзывчивым. Это мелочь, но именно такие детали в итоге могут сделать продукт классным.
Хотя порой простого изменения состояния по щелчку мыши бывает недостаточно.
Вот пример того, что я имею ввиду.
Может быть, всё дело в асимметрии, но что-то в этой анимации мне не нравится ?
А что, если иконки в состоянии наведения будут поворачиваться только на какое-то время?
Этот эффект мне нравится! Он динамический и неожиданный. Поскольку он гораздо сложнее обычного transition
, делается он особым образом.
Его можно применять довольно изощрённо, например...
По результатам неофициального опроса в Twitter poll было решено назвать этот эффект «boop». В этом уроке для intermediate-пользователей React мы разберём, как его реализовать✨
В этом уроке мы пойдем по весьма извилистому пути. Мы рассмотрим различные паттерны архитектуры React и научимся комбинировать хуки с компонентами для максимального переиспользования. Надеюсь, вам понравится!
Если не терпится взглянуть на код, это можно сделать здесь.
Первый прорыв
Самое чудное в компонентно-ориентированных фреймворках, как React — возможность упаковывать поведение практически так же, как элементы пользовательского интерфейса. Кроме элементов <Button
> и <Table
> можно создавать элементы <FadeIn
> и <SoundEffect
>.
Взять наш пример. Эффект – кратковременная активация и ее отмена – может быть отделён от любого конкретного элемента пользовательского интерфейса, так что его можно применять с чем угодно.
Первый взгляд на React-компонент:
const Boop = ({ rotation = 0, timing = 150, children }) => {
const [isBooped, setIsBooped] = React.useState(false);
const style = {
display: 'inline-block',
backfaceVisibility: 'hidden',
transform: isBooped
? `rotate(${rotation}deg)`
: `rotate(0deg)`,
transition: `transform ${timing}ms`,
};
React.useEffect(() => {
if (!isBooped) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsBooped(false);
}, timing);
return () => {
window.clearTimeout(timeoutId);
};
}, [isBooped, timing]);
const trigger = () => {
setIsBooped(true);
};
return (
<span onMouseEnter={trigger} style={style}>
{children}
</span>
);
};
Тут много кода, так что давайте разбираться.
Главная идея в том, что при наведении на элемент он переходит в другое состояние, как при использовании обычного hover-транзишна. Кроме этого, запускается таймер, и по истечении времени, состояние переключается обратно в «исходное» независимо от того, продолжаем мы наводить курсор на элемент или нет.
Получается что-то вроде тех машин, которые сами себя отключают:
Мы следим за состоянием boop с помощью стейта isBooped
.
const [isBooped, setIsBooped] = React.useState(false);
Мы обертываем то, на что наложим boop, т.е. children, в span. Это нужно, чтобы применить стиль вращения и обработать события мыши, активирующие эффект.
const trigger = () => {
setIsBooped(true);
};
return (
<span onMouseEnter={trigger} style={style}>
Небольшое отступление
Обычно лучшей практикой считается ставить обработчики событий на интерактивные элементы — кнопки или поля ввода.
Пользователи, работающие на клавиатуре, не смогут установить фокус на div или span.
Но сейчас особый случай. На самом деле я не хочу, чтобы этот эффект активировался при наведении фокуса. Он чисто декоративный и мне кажется, что при навигации с клавиатуры он будет только раздражать пользователей, состояние выделения для которых и так уже совершенно очевидно.
Используем хук эффекта, который будет срабатывать при каждом изменении isBooped
. Событие наведения приводит к переключению этого значения, которое приводит к срабатыванию хука эффекта. Эффект задает задержку, по истечении которой isBooped
вновь переключается на значение false.
React.useEffect(() => {
// We only want to act when we're going from
// not-booped to booped.
if (!isBooped) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsBooped(false);
}, timing);
// Just in case our component happens to
// unmount while we're booped, cancel
// the timeout to avoid a memory leak.
return () => {
window.clearTimeout(timeoutId);
};
// Trigger this effect whenever `isBooped`
// changes. We also listen for `timing` changes,
// in case the length of the boop delay is
// variable.
}, [isBooped, timing]);
Как насчёт самого эффекта? Пока что это только вращение. Когда isBooped истинно, мы применяем transform: rotate к оборачивающему элементу.
В разных ситуациях могут быть нужны разные эффекты, поэтому изменять угол вращения и длительность транзишна будем через пропсы. Кроме того, нам необходимо настроить display на inline-block, потому что элементы inline не трансформируемые. Также добавляем backface-visibility: hidden, чтобы воспользоваться преимуществами аппаратного ускорения*.
* Технически, эта возможность влияет на то, как наш элемент будет выглядеть, повернувшись к пользователю «спиной». Нам это неважно, зато эта возможность имеет побочный эффект: заставляет рендерить на GPU, что делает анимацию более «гладкой».
const style = {
display: 'inline-block',
backfaceVisibility: 'hidden',
transform: isBooped
? `rotate(${rotation}deg)`
: `rotate(0deg)`,
transition: `transform ${timing}ms`,
};
Новый компонент Boop
используем следующим образом:
<Boop rotation={20} timing={200}>
<UnstyledButton>
<Icon icon="gear" />
</UnstyledButton>
</Boop>
Выглядит он так...
Неплохо, неплохо. Но мы способны на большее.
Пружины спешат на помощь!
Движение этой начальной версии кажется механическим и неестественным. В ней нет ни плавности, ни естественности движений, которые мы ждем от современной анимации.
В статье «Знакомство со Spring Physics» рассказано, как придать глубину и реалистичность своим анимациям.
← Советую посмотреть, там представлены две такие забавные пружинистые демки
Моя любимая библиотека spring-physics анимации — это ReactSpring. Она предоставляет современное API, основанную на хуках, и непревзойденную производительность.
Давайте подправим наш код, чтобы использовать её вместо переходов CSS транзишнов:
import { animated, useSpring } from 'react-spring';
const Boop = ({ rotation = 0, timing = 150, children }) => {
const [isBooped, setIsBooped] = React.useState(false);
const style = useSpring({
display: 'inline-block',
backfaceVisibility: 'hidden',
transform: isBooped
? `rotate(${rotation}deg)`
: `rotate(0deg)`,
});
React.useEffect(() => {
// Unchanged
}, [isBooped, timing]);
const trigger = () => {
// Unchanged
};
return (
<animated.span onMouseEnter={trigger} style={style}>
{children}
</animated.span>
);
};
Прежде мы создавали объект style
и передавали напрямую в наш span. Теперь же мы передаем этот объект стиля (без transition
) в useSpring
.
Хук useSpring
можно сравнить с конвейерным станком, который начиняет пирожное клубничным джемом.
Иначе говоря, он берет немного обычного CSS и начиняет его ✨пружинной магией ✨. Вместо использования кривых Bézier из CSS, он будет использовать математику пружины. Вот почему мы опускаем свойства transition
: мы делегируем эту задачу ReactSpring.
Ввиду того, что springphysics пока не сильно распространен в интернете и не является стандартом, мы не можем передать этот начиненный магией стиль объекта на <span
>. Поэтому мы запускаем <animated.span
>, который идентичен прежнему и дополнительно знает, как обращаться с созданным “пружинным” объектом стиля.
Вот результат.
Кажется, немного вяло. Давайте немного изменим конфиг спринга:
const style = useSpring({
display: 'inline-block',
backfaceVisibility: 'hidden',
transform: isBooped
? `rotate(${rotation}deg)`
: `rotate(0deg)`,
config: {
tension: 300,
friction: 10,
},
});
Взводя натяжение и снижая сопротивление, мы значительно смягчаем реакцию наших значков на наведение курсора.
Вот это уже что-то!
Почему именно задержка?
Некоторые писали, что вместо использования setTimeout можно использовать колбэк onRest с APIReactSpring. Казалось бы, гораздо логичнее «отбипнуть», опираясь на саму анимацию, а не на какую-то произвольную величину времени.
На самом деле в этом случае я хочу использовать именно задержку. Причин две:1. Одно из классных преимуществ spring physics — то, как она грациозно обрабатывает прерывания. Я хочу прервать ее, чтобы отдернуть в исходное положение прежде, чем она вернется в состояние полного покоя.
2. Основная проблема с onRest в том, что он ждет, пока анимация полностью не остановится. Обычно это происходит гораздо позже воспринимаемой остановки, поскольку часто на кончике анимации бывает множество субпиксельных колебаний. Их, конечно, можно скорректировать через свойства конфигурации precision, но порой эти субпиксельные колебания придают приятный утонченный эффект.
Было бы здорово, если бы у ReactSpring был еще такой API, который позволил бы задавать процент выполнения так, чтобы я мог сказать: «Активируй этот возврат, когда анимация будет выполнена на 50%». Но на самом деле решение с задержкой, к которому я прибегнул, нисколько не хуже ?
Итак, пока что мы ограничили воздействие нашего boop на вращение, но это далеко не предел.
Давайте доработаем его так, чтобы менять размеры с помощью scale и смещать положение с помощью translate.
Добавим в transform эти свойства:
const Boop = ({
x = 0,
y = 0,
rotation = 0,
scale = 1,
timing = 150,
children,
}) => {
const [isBooped, setIsBooped] = React.useState(false);
const style = useSpring({
display: 'inline-block',
backfaceVisibility: 'hidden',
transform: isBooped
? `translate(${x}px, ${y}px)
rotate(${rotation}deg)
scale(${scale})`
: `translate(0px, 0px)
rotate(0deg)
scale(1)`,
config: {
tension: 300,
friction: 10,
},
});
// The rest is unchanged…
};
Выставляем все значения по умолчанию для обычного состояния (например, 0pxtranslate, 1xscale). Это позволяет нам указывать в пропсах только те значения, которые мы собираемся менять: если мы не передадим значение для вращения, вращения не будет.
Этот результат меня вполне устраивает, однако у этого решения есть одна серьезная проблемка. Вообще-то, нам придется полностью изменить подход!
Разделенный boop
В одном проекте, над которым я сейчас работаю, есть виджеты, которые могут разворачиваться для полного отображения контента.
Я подумал, было бы здорово, если бы стрелка подскакивала при наведении на нее курсора.
Это интересная и непростая задача, потому что здесь присутствует разделение – я хочу, чтобы бип оказывал воздействие только на стрелку, но при этом хочу, чтобы она реагировала на наведение на любую часть элемента. Если я проведу курсор над словом «Show», стрелка должна дернуться.
Наш текущий подход такой возможности не дает от слова «совсем». Анимация привязана к тому же элементу, что и обработчик событий.
Немного поэкспериментировав, я пришел к выводу, что правильным API для этого эффекта будут не компоненты, а хуки.
С потребительской точки зрения
Давайте посмотрим на всё с точки зрения пользователя.
Я позже решу, как это внедрить; прежде всего необходимо придумать самый простой и легкий интерфейс. Вот, что у меня получилось:
import { animated } from 'react-spring';
function SomeComponent() {
const [style, trigger] = useBoop({ y: 10 });
return (
<button onMouseEnter={trigger}>
Show more
<animated.span style={style}>
<Icon icon="caret-down" />
</animated.span>
</button>
);
}
Нам необходима возможность передачи нашему хуку объекта, представляющего конфигурацию, который должен предоставить две вещи:
Объект стиля, применяемый animated-элементам, например
animated.span
илиanimated.button
;Функцию запуска, для вызова ее всякий раз, как нам понадобится boop.
Если очень захочется, то мы можем применить и то и другое к одному элементу, но делать так необязательно.
Этот хук дает невероятную гибкость: его можно вызывать когда угодно и не только при наведении. К примеру, мы можем учесть пользователей мобильных телефонов, настроив действие эффекта по тапу или задать интервал срабатывания, чтобы выделить важную часть пользовательского интерфейса! *
* Вот как это реализуется:
// hooks/use-boop.js
import React from 'react';
import { useSpring } from 'react-spring';
function useBoop({
x = 0,
y = 0,
rotation = 0,
scale = 1,
timing = 150,
springConfig = {
tension: 300,
friction: 10,
},
}) {
const [isBooped, setIsBooped] = React.useState(false);
const style = useSpring({
display: 'inline-block',
backfaceVisibility: 'hidden',
transform: isBooped
? `translate(${x}px, ${y}px)
rotate(${rotation}deg)
scale(${scale})`
: `translate(0px, 0px)
rotate(0deg)
scale(1)`,
config: springConfig,
});
React.useEffect(() => {
// All the same stuff...
}, [isBooped]);
const trigger = React.useCallback(() => {
setIsBooped(true);
}, []);
return [style, trigger];
}
Much of this logic
Большая часть логики просто копируется; мы полностью повторяем действия при создании этого style-объекта. Но вместо применения его на элементе, мы просто возвращаем его из хука.
Вот еще пара фишек:
Конфигурация спринга теперь предоставляется как параметр, поскольку различные ситуации могут иметь разную физику.
Функция пуска оборачивается в
React.useCallback
. Это нужно, чтобы ссылка на функцию не менялась на каждый рендер, чтобы не допустить перерендера компонентов, обернутых вmemo
. Ведь мы не знаем, как будет использоваться функция запуска.
Возврат к компоненту
Этот хук чудесен, но мне, правда, очень понравился тот компонентный API, о котором мы говорили до этого. Можем ли мы воспользоваться компонентом в тех случаях, когда не требуется разделение между обработчиком событий и анимацией?
Что действительно круто в паттернах, так это то, что мы легко можем обернуть хук в компонент, чтобы сделать свою пироженку и съесть ее:
// components/Boop.jsx
import React from 'react';
import { animated } from 'react-spring';
import useBoop from '@hooks/use-boop';
const Boop = ({ children, ...boopConfig }) => {
const [style, trigger] = useBoop(boopConfig);
return (
<animated.span onMouseEnter={trigger} style={style}>
{children}
</animated.span>
);
};
Наш Boop-компонент стал намного меньше, потому что мы делегировали всю тяжелую работу хуку useBoop.Теперь
у нас есть доступ к двум превосходным API, которые действуют на основе одной логики. DRY AF.
Встает на место?
В зависимости от вида анимации, иногда вы сможете заметить, что при завершении она как будто «встает на место».
Это легкое смещение на один-два пикселя. Оно особенно распространено при анимации элемента, содержащего текст:
Вы можете узнать больше, почему это происходит, и как это поправить: «Интерактивное руководство по CSS-переходам».
Сохраняйте доступность
Комбинация «компонент/хук», что мы создали, очаровательна, но очарование – вещь субъективная. Не все хотят видеть скачущий UI, особенно люди с расстройствами вестибулярного аппарата.
Я как-то уже писал, как создавать удобные анимации в React. Давайте применим эти знания и здесь:
// hooks/use-boop.js
import React from 'react';
import { useSpring } from 'react-spring';
function useBoop({
rotation = 0,
timing = 150,
springConfig = {
tension: 300,
friction: 10,
},
}) {
const prefersReducedMotion = usePrefersReducedMotion();
const [isBooped, setIsBooped] = React.useState(false);
const style = useSpring({
// All the same stuff
});
React.useEffect(() => {
// All the same stuff here as well...
}, [isBooped]);
const trigger = React.useCallback(() => {
// Yep yep
}, []);
let applicableStyle = prefersReducedMotion ? {} : style;
return [applicableStyle, trigger];
}
Хук prefers-reduced-motion сообщит нам, если пользователь захочет удалить движение. Когда это значение true, мы возвращаем «пустой» объект стиля. Учитывая, что объект стиля всегда пустой, мы будем уверены, что элемент не сдвинется.
Весь ваш для новых открытий
Прежде всего – спасибо, что дочитали до этого места! Это было непростое путешествие?
Держите итоговую версию, чтобы скопировать и вставить в свой репозиторий:
код
import React from 'react';
import { useSpring } from 'react-spring';
// UPDATE this path to your copy of the hook!
// Source here: https://joshwcomeau.com/snippets/react-hooks/use-prefers-reduced-motion
import usePrefersReducedMotion from '@hooks/use-prefers-reduced-motion.hook';
function useBoop({
x = 0,
y = 0,
rotation = 0,
scale = 1,
timing = 150,
springConfig = {
tension: 300,
friction: 10,
},
}) {
const prefersReducedMotion = usePrefersReducedMotion();
const [isBooped, setIsBooped] = React.useState(false);
const style = useSpring({
transform: isBooped
? `translate(${x}px, ${y}px)
rotate(${rotation}deg)
scale(${scale})`
: `translate(0px, 0px)
rotate(0deg)
scale(1)`,
config: springConfig,
});
React.useEffect(() => {
if (!isBooped) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsBooped(false);
}, timing);
return () => {
window.clearTimeout(timeoutId);
};
}, [isBooped]);
const trigger = React.useCallback(() => {
setIsBooped(true);
}, []);
let appliedStyle = prefersReducedMotion ? {} : style;
return [appliedStyle, trigger];
}
export default useBoop;
Link t
Бонус: анимация звезды
Среди демок, показанных в начале, была звездообразная анимация.
Этот эффект действительно выполнен с использованием хука useBoop
, который мы создали, но в нем также задействована тригонометрия, рассмотрение которой не укладывается в рамки данного туториала.
Сейчас я нахожусь в процессе написания поста о том, как использовать тригонометрию для создания эффектов, подобных этому.
Пока же поделюсь кодом, максимально расписав контекст в комментариях! Надеюсь, это поможет.?
анимация звезды
import React from 'react';
import styled from 'styled-components';
import { animated, useSpring } from 'react-spring';
import { Star } from 'react-feather';
import useBoop from '@hooks/use-boop.hook';
import UnstyledButton from '@components/UnstyledButton';
import Spacer from '@components/Spacer';
const useAngledBoop = (index) => {
// Our star has 5 points across a 360-degree area.
// Our first point should shoot out at 0 degrees,
// our second at 72 degrees (1/5th of 360),
// our third at 144 degrees, and so on.
let angle = index * (360 / 5);
// By default in JS, 0-degrees is the 3-o'clock
// position, but I want my animation to start at
// the 12-o'clock position, so I'll subtract
// 90 degrees
angle -= 90;
// Trigonometry methods in JS use radians, not
// degrees, so we need to convert.
const angleInRads = (angle * Math.PI) / 180;
// If this was meant to be reusable, this would
// be configurable, but it's not, so it's
// hardcoded. The # of pixels from the center
// that our circle will bounce.
const distance = 42;
// Convert polar coordinages (angle, distance)
// to cartesian ones (x, y), since JS uses
// a cartesian coordinate system:
const x = distance * Math.cos(angleInRads);
const y = distance * Math.sin(angleInRads);
// `normalize` is commonly called "lerp",
// as well as Linear Interpolation. It
// maps a value from one scale to another.
// In this case, I want the time to vary
// between 450ms and 600ms, with the first
// point being the slowest, and the last
// one being the fastest.
//
// It's defined below
let timing = normalize(index, 0, 4, 450, 600);
// `normalize` produces linear interpolation,
// but I want there to be a *bit* of an ease;
// I want it to appear to be slowing down,
// as we get further into the circles.
timing *= 1 + index * 0.22;
const friction = normalize(index, 0, 4, 15, 40);
const boop = useBoop({
x,
y,
timing,
scale: 1.4,
springConfig: { tension: 180, friction },
});
return boop;
};
const CircleDemo = () => {
const [c1s, c1t] = useAngledBoop(0);
const [c2s, c2t] = useAngledBoop(1);
const [c3s, c3t] = useAngledBoop(2);
const [c4s, c4t] = useAngledBoop(3);
const [c5s, c5t] = useAngledBoop(4);
const [starStyles, starTrigger] = useBoop({
scale: 1.1,
rotation: 10,
timing: 150,
springConfig: {
tension: 300,
friction: 6,
},
});
return (
<Wrapper>
<Button
onMouseEnter={() => {
// If I had more than 5 points, I might
// write a `callAll()` helper function.
// But I don't, so this is fine.
c1t();
c2t();
c3t();
c4t();
c5t();
starTrigger();
}}
>
<IconWrapper style={starStyles}>
<Star size={48} />
</IconWrapper>
</Button>
<Circle style={c1s} />
<Circle style={c2s} />
<Circle style={c3s} />
<Circle style={c4s} />
<Circle style={c5s} />
</Wrapper>
);
};
// This helper function is used in the component
const normalize = (
number,
currentScaleMin,
currentScaleMax,
newScaleMin = 0,
newScaleMax = 1
) => {
// FIrst, normalize the value between 0 and 1.
const standardNormalization =
(number - currentScaleMin) / (currentScaleMax - currentScaleMin);
// Next, transpose that value to our desired scale.
return (
(newScaleMax - newScaleMin) * standardNormalization + newScaleMin
);
};
// My project uses styled-components.
// Nothing here is styled-components-specific,
// however. It's just the tool I was already
// using.
const Wrapper = styled.div`
position: relative;
width: min-content;
`;
const Button = styled(UnstyledButton)`
position: relative;
z-index: 3;
padding: 8px;
border-radius: 50%;
`;
const IconWrapper = styled(animated.span)`
display: block;
svg {
display: block;
stroke: var(--color-text) !important;
fill: var(--color-background) !important;
}
`;
const Circle = styled(animated.div)`
position: absolute;
z-index: 1;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 8px;
height: 8px;
margin: auto;
border-radius: 50%;
background: hsl(50deg, 100%, 48%);
`;
export default CircleDemo;
Lin
Другие статьи про frontend для начинающих:
Как работают браузеры: навигация и получение данных, парсинг и выполнение JS, деревья спец возможностей и рендеринга
Другие статьи про frontend для продвинутых: