React - это классный фреймворк и я с удовольствием работаю с ним в SPA-приложениях. Недавно я решил добавить анимацию в свой проект и попробовать React Motion. К сожалению, надлежащих учебников по нему нет, поэтому решено было изложить все наработки здесь для коллег-разработчиков и себя, любимого.
React Motion экспортирует 3 основных компонента: Motion
, StaggeredMotion
и TransitionMotion
. В этом уроке мы рассмотрим компонент Motion
, который вы будете использовать чаще всего.
Поскольку это учебное пособие по React Motion, я предполагаю, что вы немного знакомы с React и ES2015. Мы будем изучать API React Motion при работе над следующим примером:
Сначала мы будем заниматься математикой. Не волнуйтесь, я все подробно объясню шаг за шагом. Если математика вам не интересна, вы можете сразу перейти к разделу React.start();.
Math.start();
Давайте назовем нашу большую голубую кнопку - Основной кнопкой (main button), а кнопки, которые вылетают из нее, - дочерними (child buttons).
Дочерние кнопки имеют два позиционных состояния: 1) кнопки скрыты за основной кнопкой, 2) дочерние кнопки располагаются по кругу вокруг основной кнопки.
Вот где пригодится математика: нам придется придумать способ равномерно расположить по кругу дочерние кнопки вокруг основной кнопки. Вы могли бы жестко прописать их координаты методом проб и ошибок, но это не правильно. Более того, получив формулу расположения, вы сможете задать любое количество дочерних кнопок и все они автоматически расположатся вокруг основной кнопки. Прежде всего, давайте познакомимся с несколькими терминами.
M_X, M_Y
M_X, M_Y - координаты x и y центра основной кнопки. Из этой точки будут вычисляться расстояния и направления каждой дочерней кнопки. Каждая дочерняя кнопка сначала скрывается за главной кнопкой со своими центрами в M_X, M_Y.
Разделительный угол, угол вращения, радиус вылета
Радиус вылета - это расстояние от основной кнопки, на которое вылетают дочерние кнопки. Все остальное выглядит довольно понятным. Также обратите внимание, что,
Угол вращения = (число дочерних кнопок-1) * Разделительный угол
Теперь нам нужно создать функцию, которая принимает индекс дочерней кнопки (0, 1, 2, 3 ...) и возвращает координаты x и y новой позиции дочерней кнопки (после вылета).
Базовый угол, индекс
Поскольку в общем случае тригонометрические углы измеряются от положительной оси x, мы начнем нумерацию наших дочерних кнопок с противоположного (справа налево) направления. Таким образом, позже нам не придется иметь дело с умножением на -1
каждый раз, когда нам нужно найти конечную позицию дочерней кнопки.
Пока мы здесь, обратите внимание, что (см. Фиг. 3):
Базовый угол = (180 — Угол вращения)/2 (в градусах, конечно же).
Угол
Каждая дочерняя кнопка будет иметь свой собственный угол, который я назову Angle, да просто Angle. Этот угол - последняя часть информации, которая нам нужна для вычисления конечной позиции дочерних кнопок. Обратите внимание, что (см. Фиг. 3, Фиг. 4)
Угол дочерней кнопки с индексом i = Базовый угол + ( i * Разделительный угол)
Теперь, когда у нас есть угол для каждой дочерней кнопки...
...мы сможем рассчитать deltaX и deltaY для этой дочерней кнопки. Обратите внимание, что (см. Фиг. 2):
Конечная позиция X дочерней кнопки = M_X + deltaX
Конечная позиция Y дочерней кнопки = M_Y - deltaY
Мы вычитаем deltaY из M_Y, потому что в отличие от общей системы координат, где начало координат находится в левом нижнем углу, у браузеров начало кординат в левом верхнем углу, поэтому для перемещения чего-либо вы уменьшаете значение своей y-координаты.
На этом мы заканчиваем заниматься математикой, поскольку теперь у нас есть начальная позиция (M_X, M_Y) и конечное положение каждой из дочерних кнопок. Предоставим React сделать остальную работу.
React.start();
В следующем примере вы увидите, что происходит, при нажатии на основную кнопку: мы устанавливаем переменную состояния isOpen в true
(строка 85). Как только isOpen истинно, передается другой набор стилей для дочерних кнопок (строка 97, строка 66, строка 75).
'use strict';
import React from 'react';
// range(4) возвращает массив [0, 1, 2, 3],
// range(2) возвращает массив [0, 1]
// и так далее
import range from 'lodash.range';
// Компоненты
// Константы
// Значение 1 градуса в радианах
const DEG_TO_RAD = 0.0174533;
// Диаметр основной кнопки в пикселях
const MAIN_BUTTON_DIAM = 90;
const CHILD_BUTTON_DIAM = 50;
// Число дочерних кнопок, вылетающих из главной кнопки
const NUM_CHILDREN = 5;
// Жестко заданные значения позиции главной кнопки
const M_X = 490;
const M_Y = 450;
// Как далеко от основной кнопки находятся дочерние кнопки
const FLY_OUT_RADIUS = 120,
SEPARATION_ANGLE = 40, //в градусах
FAN_ANGLE = (NUM_CHILDREN - 1) * SEPARATION_ANGLE, //в градусах
BASE_ANGLE = ((180 - FAN_ANGLE)/2); // в градусах
// Вспомогательные функции
// Поскольку JS-функция Math принимает значение угла в радианах, а у нас
// углы вычислены в градусах, нам придется перевести их в радианы
function toRadians(degrees) {
return degrees * DEG_TO_RAD;
}
function finalDeltaPositions(index) {
let angle = BASE_ANGLE + ( index * SEPARATION_ANGLE );
return {
deltaX: FLY_OUT_RADIUS * Math.cos(toRadians(angle)) - (CHILD_BUTTON_DIAM/2),
deltaY: FLY_OUT_RADIUS * Math.sin(toRadians(angle)) + (CHILD_BUTTON_DIAM/2)
};
}
class APP extends React.Component {
constructor(props) {
super(props);
this.state = {
isOpen: false
};
// Привязываем this к функции
this.openMenu = this.openMenu.bind(this);
}
mainButtonStyles() {
return {
width: MAIN_BUTTON_DIAM,
height: MAIN_BUTTON_DIAM,
top: M_Y - (MAIN_BUTTON_DIAM/2),
left: M_X - (MAIN_BUTTON_DIAM/2)
};
}
initialChildButtonStyles() {
return {
width: CHILD_BUTTON_DIAM,
height: CHILD_BUTTON_DIAM,
top: M_Y - (CHILD_BUTTON_DIAM/2),
left: M_X - (CHILD_BUTTON_DIAM/2)
};
}
finalChildButtonStyles(childIndex) {
let{deltaX, deltaY} = finalDeltaPositions(childIndex);
return {
width: CHILD_BUTTON_DIAM,
height: CHILD_BUTTON_DIAM,
left: M_X + deltaX,
top: M_Y - deltaY
};
}
openMenu() {
let{isOpen} = this.state;
this.setState({
isOpen: !isOpen
});
}
render() {
let {isOpen} = this.state;
return (
<div>
{range(NUM_CHILDREN).map( index => {
let style = isOpen ? this.finalChildButtonStyles(index) : this.initialChildButtonStyles();
return (
<div
key={index}
className="child-button"
style={style}/>
);
})}
<div
className="main-button"
style={this.mainButtonStyles()}
onClick={this.openMenu}/>
</div>
);
}
};
module.exports = APP;
В результате получим вот что:
Мы устанавливаем начальное и конечное положение дочерних кнопок при нажатии. Все, что нам нужно сделать, - это добавить React Motion, чтобы сделать плавным перемещение дочерних кнопок от начальной к их конечной позициям.
React-Motion.start();
<Motion>
принимает несколько параметров, один из которых является необязательным. Мы не особо заботимся о необязательном параметре, так как не делаем ничего, с чем связан этот параметр.
Первый параметр <Motion>
называется style. Этот стиль затем передается в качестве параметра в функцию обратного вызова, который принимает интерполированные значения и делает свое дело.
Вот наш код (строка 8 : React требует передачи параметра key для дочерних компонентов):
render() {
let {isOpen} = this.state;
return (
<div>
{range(NUM_CHILDREN).map( index => {
let style = isOpen ? this.finalChildButtonStyles(index) : this.initialChildButtonStyles();
return (
<Motion style={style} key={index}>
{({width, height, top, left}) =>
<div
className="child-button"
style=/>
}
</Motion>
);
})}
<div
className="main-button"
style={this.mainButtonStyles()}
onClick={this.openMenu}/>
</div>
);
}
Даже после этого результат не будет отличаться от Фиг. 7. Почему вы спрашиваете? Что ж, остался последний шаг - spring.
Как упоминалось ранее, функция обратного вызова принимает интерполированные значения, это то, что делает spring для интерполяции значений стиля.
Нам нужно будет отредактировать initialChildButtonStyles и finalChildButtonStyles:
initialChildButtonStyles() {
return {
width: CHILD_BUTTON_DIAM,
height: CHILD_BUTTON_DIAM,
top: spring(M_Y - (CHILD_BUTTON_DIAM/2)),
left: spring(M_X - (CHILD_BUTTON_DIAM/2))
};
}
finalChildButtonStyles(childIndex) {
let{deltaX, deltaY} = finalDeltaPositions(childIndex);
return {
width: CHILD_BUTTON_DIAM,
height: CHILD_BUTTON_DIAM,
left: spring(M_X + deltaX),
top: spring(M_Y - deltaY)
};
}
Обратите внимание на значения top и left, обернутые вокруг spring. Это единственные изменения, и теперь:
Опционально spring принимает второй параметр, который представляет собой массив, содержащий два числа [жесткость, демпфирование] (по умолчанию это [170, 26]), что приводит к тому, что вы видите выше на рис. 8.
Скорость, с которой проходит анимация довольно неточно приближена, но чем выше значение жесткости, тем выше скорость. А демпфирование – это как люфт, но напротив, чем меньше значение, тем его меньше. Взгляните:
Что если бы мы добавляли задержку каждый раз перед началом анимации следующей дочерней кнопки? Это именно то, что нам нужно сделать, чтобы достичь конечного эффекта. Однако это не так просто, нам нужно хранить каждый компонент движения в виде массива в переменной состояния. Затем нужно изменить состояния одно за другим для каждой из дочерних кнопок, чтобы достичь нужного эффекта. Код будет выглядеть примерно так:
this.state = {
isOpen: false,
childButtons: []
};
Затем в componentDidMount поместите childButtons
componentDidMount() {
let childButtons = [];
range(NUM_CHILDREN).forEach(index => {
childButtons.push(this.renderChildButton(index));
});
this.setState({childButtons: childButtons.slice(0)});
}
Функция openMenu станет такой:
openMenu() {
let{isOpen} = this.state;
this.setState({
isOpen: !isOpen
});
range(NUM_CHILDREN).forEach((index) => {
let {childButtons} = this.state;
setTimeout(() => {
childButtons[NUM_CHILDREN - index - 1] = this.renderChildButton(NUM_CHILDREN - index - 1);
this.setState({childButtons: childButtons.slice(0)});
}, index * 100);
});
}
Вот и все. Сделав небольшие эстетические настройки (добавление значков и небольшое вращение), мы получаем следующее:
Обратите внимание, что вы можете разместить столько дочерних кнопок, сколько захотите:
На этом все. Вы можете скачать код здесь. Не стесняйтесь внести в него изменения и лайкнуть кнопку с соц. иконками ниже.)