Мы разрабатываем веб-приложение на 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} />
    );
};