Волновой эффект (рябь) в React
Многие из нас видели анимацию волнового эффекта, которая была частью Material Design. Рябь представляет собой круг, который появляется в точке щелчка, а затем увеличивается и исчезает. Как инструмент пользовательского интерфейса, это фантастический и знакомый способ сообщить пользователю, что произошел щелчок.
Несмотря на то, что в Vanilla JS эффект ряби совершенно выполним, я хотел найти способ его интеграции с React. Самый простой способ — использовать Material-UI, который является популярной UI библиотекой. В общем, это очень хорошая идея, если тебе нужна солидная UI библиотека, которая генерирует UI из коробки. Однако, для небольшого проекта не имеет смысла учиться работать с большой библиотекой только для достижения одного эффекта.
Я просмотрел множество проектов, реализующих нечто подобное в Github, Codepen и Codesandbox, и черпал вдохновение в некоторых из лучших. Эффект ряби возможен на любой веб-платформе, так как он достигается при помощи CSS.
Если ты хочешь сразу перейти к коду и пропустить объяснение — можешь посмотреть его здесь (React + TS).
Вот как выглядит моя имплементация CSS.
<button class="button"> <div class="ripple-container"> <span class="ripple"></span> </div> </button>
.button { overflow: hidden; position: relative; } .ripple-container { top: 0; left: 0; right: 0; bottom: 0; z-index: 0; overflow: hidden; position: absolute; } .ripple-container span { position: absolute; top: ...; left: ...; height: ...; width: ...; transform: scale(0); border-radius: 100%; opacity: 0.25; background-color: #fff; animation-name: ripple; animation-duration: 850ms; } @keyframes ripple { to { opacity: 0; transform: scale(2); } }
Свойство overflow: hidden
предотвращает выход ряби из контейнера. Рябь — это круг (border-radius: 100%), который начинается с небольшого размера и становится больше по мере исчезновения. Растущая и исчезающая анимация достигается путем манипулирования transform: scale и opacity.
Однако, нам нужно будет динамически предоставить несколько стилей с использованием Javascript. Найти позиционные координаты: то есть top
и left
, которые основаны на том, где пользователь щелкнул, а также height
и width
, которые зависят от размера контейнера.
Начнем
Для своих стилей мне удобно использовать стилевые компоненты. Ты же можешь использовать то, что предпочитаешь. Первое, что мы сделаем — добавим вышеуказанный CSS в наши компоненты. Стили будут находиться в отдельном файле.
// file => styles.ts import styled from "styled-components"; export const Button = styled.button` position: relative; padding: 5px 30px; overflow: hidden; cursor: pointer; background-color: ${(props) => (props.color ? props.color : "tomato")}; color: #fff; font-size: 20px; border-radius: 20px; border: 1px solid #fff; text-align: center; box-shadow: 0 0 5px rgba(0, 0, 0, 0.4); box-sizing: border-box; `; export const RippleContainer = styled.div` top: 0; left: 0; right: 0; bottom: 0; z-index: 0; overflow: hidden; position: absolute; border-radius: inherit; span { position: absolute; transform: scale(0); border-radius: 100%; opacity: 0.25; background-color: ${(props) => props.color}; animation-name: ripple; animation-duration: ${(props) => props.duration}ms; } @keyframes ripple { to { opacity: 0; transform: scale(2); } } `;
Обрати внимание, что для кнопки background-color
зависит от props color
самого стилевого компонента, но если он не будет передан, то по умолчанию будет фон tomato
. Для контейнера тоже самое. Плюс длительность анимации зависит от props. Это сделано для того, чтобы мы могли динамически устанавливать эти значения позже. Давай определим их сейчас:
import React from 'react' ... export function Ripple({ duration = 850, color = "#ffffff" }) { ... }
Далее для нашей ряби мы определяем массив и создаём функцию для добавления ряби в этот массив. Каждый элемент массива будет объектом со свойствами x
, y
и size
, которые являются информацией, необходимой для стилизации ряби. Чтобы вычислить эти значения, мы извлечем их из события mousedown.
export function Ripple({ duration = 850, color= '#ffffff' }) { const [rippleArray, setRippleArray] = React.useState([]); const addRipple = (event) => { // Координаты контейнера const rippleContainer = event.currentTarget.getBoundingClientRect(); // Выбираем самую длинную сторону const size = rippleContainer.width > rippleContainer.height ? rippleContainer.width : rippleContainer.height; // Координаты щелчка мыши const x = event.pageX - rippleContainer.left - size / 2; const y = event.pageY - rippleContainer.top - size / 2; // Новая рябь const newRipple = { x, y, size }; setRippleArray([...rippleArray, newRipple]); }; return ( ... ); }
Приведенный выше код использует API браузера getBoundClientRect()
, который позволяет нам получить самый длинный край контейнера и координаты x
(left) и y
(top), относительно документа. Вместе с MouseEvent.pageX
и MouseEvent.pageY
он позволяет нам вычислять координаты x
и y
мыши (щелчка) относительно контейнера. Если ты хочешь узнать больше о том, как они работают, есть более подробное объяснение для getBoundClientRect, MouseEvent.pageX и MouseEvent.pageY в MDN документации.
Теперь можем визуализировать массив ряби.
return ( <RippleContainer duration={duration} color={color} onMouseDown={addRipple}> {rippleArray.length && rippleArray.map((ripple, index) => { return ( <span key={"span" + index} style={{ top: ripple.y, left: ripple.x, width: ripple.size, height: ripple.size, }} /> ); })} </RippleContainer> );
RippleContainer — это стилизованный компонент, который принимает duration
и color
в качестве props
, вместе с addRipple
в качестве обработчика события onMouseDown
. Внутри него мы отображаем все наши ряби и добавляем рассчитанные параметры top, left, width и height.
С этим мы закончили. Тем не менее, есть ещё одна маленькая вещь, которую нужно сделать, а именно: очистить ряби после того, как они сделали анимацию. Это сделано для того, чтобы устаревшие элементы не загромождали DOM.
Ниже приведена реализация и использование useEffect
хука. По существу, каждый раз, когда мы создаем новую рябь, таймер сбрасывается. Обрати внимание, что продолжительность тайм-аута намного больше, чем наша рябь.
React.useEffect(() => { let bounce; if (rippleArray.length > 0) { window.clearTimeout(bounce); bounce = window.setTimeout(() => { setRippleArray([]); window.clearTimeout(bounce); }, duration * 4); } return () => window.clearTimeout(bounce); }, [rippleArray.length, duration]);
Теперь мы закончили с нашим компонентом Ripple. Давай создадим кнопки.
import React from "react"; import "./App.css"; import { Ripple } from "./RippleButton"; import { Button } from "./RippleButton/styles"; function App() { return ( <div className="App"> <header className="App-header"> <Button> Learn React <Ripple duration={850} color="#fff" /> </Button> <Button> Learn React <Ripple duration={1000} color="#fff" /> </Button> <Button> Learn React <Ripple duration={2000} color="#fff" /> </Button> </header> </div> ); } export default App;
Теперь у нас есть рябь во всех оттенках и скоростях. Более того, наш компонент Ripple
, если он имеет overflow: hidden
и position: relative
в своих стилях, может быть повторно использован практически в любом контейнере.