Мы разрабатываем веб-приложение на React, и вдруг мы хотим добавить красивую анимацию.
На написание этой истории меня вдохновили следующие требования:
- Анимация чувствительна к частоте и скорости обновления приложений.
- Анимация подходит для элементов SVG.
- Анимация может использоваться повторно и может применяться к любым компонентам пользовательского интерфейса.
- Анимация эффективна и быстра для всех веб-браузеров и платформ.
Давайте начнем
Я буду писать код на Typescript и React. Вы можете настроить собственное приложение React + Typescript, выполнив одну команду:
npx create-react-app stopwatch --typescript
Создайте компонент React с содержимым SVG, напоминающим секундомер.
import * as React from "react"; import * as ReactDOM from "react-dom"; function degreesToRadians(degrees: number): number { return degrees / 180 * Math.PI - Math.PI / 2; } const radius = 100; const size = radius * 2; interface Props { initialDegree: number; } interface State { degree: number; } class StopWatch extends React.Component<Props, State> { public constructor(props: StopWatchProps) { super(props); this.state = { degree: props.initialDegree }; } public render() { const radians = degreesToRadians(this.state.degree); // line begin at the circle center const lineX1 = radius; const lineY1 = radius; // Calculate line end from parametric expression for circle const lineX2 = lineX1 + radius * Math.cos(radians); const lineY2 = lineY1 + radius * Math.sin(radians); return ( <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} > <circle cx={radius} cy={radius} r={radius} fill="yellow" /> <line x1={lineX1} y1={lineY1} x2={lineX2} y2={lineY2} strokeWidth="1" stroke="red" /> </svg> ); } } ReactDOM.render( <StopWatch initialDegree={0} />, document.getElementById("root") );
Мы получаем статическое изображение SVG со стрелкой в начальной позиции, initialDegree
.
Приводить в движение
Для анимации стрелки нам понадобятся две вещи:
- Метод обновления, который пересчитывает новый угол стрелки.
- Цикл анимации, запускающий функцию обновления.
Давайте добавим наш метод обновления и цикл анимации внутри класса StopWatch
.
public componentDidMount() { this.update(); } private increment = 1; private update = () => { this.setState( (previous: State): State => { return { degree: (previous.degree + this.increment) % 360 }; }, ); window.requestAnimationFrame(this.update); };
Обработчик React componentDidMount
безопасно вызывает метод update
при первом монтировании компонента, чтобы предотвратить предупреждение: can't call setState on a component that is not yet mounted
.
При каждом вызове update
мы увеличиваем состояние на 1 градус в диапазоне от 0 до 259. В последней строке requestAnimationFrame
указывает браузеру выполнить следующую перерисовку анимации для того же метода update
. Как правило, это происходит примерно 60 раз в секунду (~ 60FPS), но это зависит от производительности браузера и устройства.
В результате получаем движение стрелки.
Настраиваемая частота кадров
Мы знаем, что requestAnimationFrame
обычно дает нам ~ 60 FPS, поэтому практически мы можем рассчитать перерисовку компонентов от 1 до 60 раз в секунду.
Добавьте свойство frameRate
в интерфейс компонента, и в дальнейшем мы передадим его как компонент prop
.
interface Props { initialDegree: number; frameRate: number; }
Добавьте текстовую метку с текущим FPS
к элементу SVG в методе render
.
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} > <circle cx={radius} cy={radius} r={radius} fill="yellow" /> <line x1={lineX1} y1={lineY1} x2={lineX2} y2={lineY2} strokeWidth="1" stroke="red" /> <text x="70" y="50" fill="black"> {`FPS: ${this.props.frameRate}`} </text> </svg>
Теперь добавьте счетчик базовых кадров к методу update
для оценки того, сколько кадров нам следует подождать до следующей перерисовки анимации.
private maxFPS = 60; private frameCount = 0; private update = () => { this.frameCount++; if (this.frameCount >= Math.round( this.maxFPS / this.props.frameRate )) { this.setState( (previous: State): State => { return { degree: (previous.degree + this.increment) % 360 }; }, ); this.frameCount = 0; } window.requestAnimationFrame(this.update); }
Измените increment
на 3, передайте свойство frameRate
при инициализации StopWatch
и добавьте еще несколько примеров.
private increment = 3; const App = () => ( <div style={{display: "flex"}}> <StopWatch initialDegree={0} frameRate={60} /> <StopWatch initialDegree={0} frameRate={30} /> <StopWatch initialDegree={0} frameRate={20} /> </div> ); ReactDOM.render( <App />, document.getElementById("root") );
Если мы хотим, чтобы стрелки имели ту же скорость вращения, несмотря на частоту кадров, что и в титульном изображении, нам нужно вычислить increment
в соответствии с текущим FPS.
public constructor(props: Props) { super(props); this.state = { degree: props.initialDegree }; this.increment = this.maxFPS / props.frameRate; }
Последний шаг
Чтобы сделать наш окончательный результат модульным, давайте переместим функции анимации в отдельный файл и сделаем его многоразовым.
Для этой цели мы будем использовать шаблон компонент более высокого порядка.
import * as React from "react"; export type BaseProps = Readonly<{ frameRate: number; }>; export type Options<Props extends BaseProps> = Readonly<{ update: (state: Props) => Props; }>; export const MAX_FPS = 60; export const withAnimation = <Props extends BaseProps>( options: Options<Props> ) => { return( Component: React.ComponentType<Props> ): React.ComponentClass<Props> => { return class Animation extends React.Component< Props, Props > { private frameCount = 0; private frameId = 0; constructor(props: Props) { super(props); this.state = props; } public render() { return <Component {...this.state} />; } public componentDidMount() { this.update(); } public componentWillUnmount() { if (this.frameId) { window.cancelAnimationFrame(this.frameId); } } private update = () => { this.frameCount++; if (this.frameCount >= Math.round( MAX_FPS / this.props.frameRate )) { this.setState(options.update); this.frameCount = 0; } this.frameId = window.requestAnimationFrame(this.update); }; } }; };
А теперь StopWatch
выглядит намного проще.
import * as React from "react"; import * as ReactDOM from "react-dom"; import { BaseProps, withAnimation } from "./reactFrameRate"; function degreesToRadians(degree: number): number { return degree / 180 * Math.PI - Math.PI / 2; } const radius = 100; const size = radius * 2; type Props = Readonly<{ degree: number; }> & BaseProps; const StopWatch: React.SFC<Props> = props => { const radians = degreesToRadians(props.degree); const lineX1 = radius; const lineY1 = radius; const lineX2 = lineX1 + radius * Math.cos(radians); const lineY2 = lineY1 + radius * Math.sin(radians); return ( <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} > <circle cx={radius} cy={radius} r={radius} fill="yellow" /> <line x1={lineX1} y1={lineY1} x2={lineX2} y2={lineY2} strokeWidth="1" stroke="red" /> <text x="70" y="50" fill="black"> {`FPS: ${props.frameRate}`} </text> </svg> ); }; const options = { update: (props: Props): Props => { return { ...props, degree: (props.degree + 180 / props.frameRate) % 360 }; } }; const WithAnimation = withAnimation(options)(StopWatch); const App = () => ( <div style={{display: "flex"}}> <WithAnimation degree={0} frameRate={30} /> <WithAnimation degree={0} frameRate={10} /> <WithAnimation degree={0} frameRate={5} /> </div> ); ReactDOM.render( <App />, document.getElementById("root") );
Последние мысли
Есть еще несколько способов улучшить код и повысить производительность, но я надеюсь, что эта статья будет для вас полезной, и этот подход найдет место в ваших проектах React.
Некоторые обновления
Если есть необходимость остановить анимацию в определенный момент. Одним из вариантов может быть обновление флага isAnimating
из закрытой области внутри функции updateState
. Представлю его в стиле хуков React:
/* constants */ const frameRate = 60; const initialDeg = 0; /* main application */ const App = () => { /* store the animation toggle flag in state hook */ const [ isAnimating, setAnimating, ] = React.useState<boolean>(true); const updateState = React.useCallback< (state: Props) => Props >((state: Props) => { const newDeg = state.deg + 1; /* stop animation when the angle approaches 270 degrees */ if (newDeg >= 270) { setAnimating(false); } return { ...state, deg: newDeg, }; }, []); const options = { updateState, frameRate, }; const WithAnimation = React.useMemo(() => { return withReactFrameRate<Props>(options)(Circle); }, []); return ( <WithAnimation deg={initialDeg} isAnimating={isAnimating} /> ); };