Волновой эффект в React

3885

Волновой эффект (рябь) в 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" }) {

  ...

}

Далее для нашей ряби мы определяем массив и создаём функцию для добавления ряби в этот массив. Каждый элемент массива будет объектом со свойствами xy и 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 мыши (щелчка) относительно контейнера. Если ты хочешь узнать больше о том, как они работают, есть более подробное объяснение для getBoundClientRectMouseEvent.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 в своих стилях, может быть повторно использован практически в любом контейнере.

источник