Давайте прямо. Вы слышали о материальном дизайне и хотели бы применить эффект ряби в своих собственных приложениях React. Конечно, есть библиотеки, такие как material-ui, которые уже реализовали их, но вам действительно не хочется использовать одну из них. Вам просто нужен один <Ripple> компонент, который вы можете применить к другим элементам (кнопке, переключателю, элементу списка или чему-то еще).

Вот что я создал - реакция-прикосновение-рябь. Он легкий, простой в использовании, и вы можете использовать его где угодно для вашего удобства. Если вам все равно, как это работает, вы можете прекратить читать сейчас и взять его в npm.

Для людей, которые готовы погрузиться в это глубже, я покажу вам, как я создаю это шаг за шагом. Очевидно, что здесь не будут рассмотрены все детали, но вы всегда можете проверить исходный код, если хотите.

Что будем делать?

Мы разделим на два компонента: Ripple и RippleWrapper.

Компонент Ripple - это сам маленький круг. Он появится, когда вы где-то щелкнете (очевидно, мы хотим, чтобы он отображался именно в том месте, где вы щелкаете), и через короткий промежуток времени рассеется.

Компонент RippleWrapper - это, по сути, оболочка. Это контейнер для Ripple (ов), так как у вас может быть несколько ripple компонентов, если вы быстро нажимаете. Кроме того, мы привяжем всех слушателей к компоненту RippleWrapper, чтобы он взял на себя ответственность за обработку всей логики.

‹Ripple›

Компонент Ripple довольно прост. Он состоит из двух этапов: вход и выход.

  • На этапе ввода размер ряби становится больше, поэтому мы применим к ней transform: scale(0) и transform: scale(1). В то же время мы также увеличиваем его прозрачность, добавляя opacity: 0 к opacity: 0.3.
  • На этапе выхода единственное, что нас волнует, - это прозрачность, поэтому устанавливаем opacity: 0, и все готово.

Следовательно, мы можем написать некоторый CSS примерно так:

.ripple-entering {
    opacity: 0.3;
    transform: scale(1);
    animation: ripple-enter 500ms cubic-bezier(0.4, 0, 0.2, 1)
}
.ripple-exiting {
    opacity: 0;
    animation: ripple-exit 500ms cubic-bezier(0.4, 0, 0.2, 1);
}

@keyframes ripple-enter {
    0% { transform: scale(0); }
    100% { transform: scale(1); }
}

@keyframes ripple-exit {
    0% { opacity: 1; }
    100% { opacity: 0; }
}

Мы можем контролировать это в компоненте React react-transition-group. Что он делает, так это когда компонент Ripple монтируется, он запускает событие onEnter, которое мы можем взять под контроль и выполнить анимацию оттуда. Кстати, react-transition-group - это в основном единственная зависимость, которую мы используем помимо react.

class Ripple extends React.Component {
    state = {
        rippleEntering: false,
        rippleExiting: false,
    };

    handleEnter = () => {
        this.setState({ rippleEntering: true, });
    }

    handleExit = () => {
        this.setState({ rippleExiting: true, });
    }

    render () {
        const { className, rippleX, rippleY, rippleSize, color, timeout, ...other } = this.props;
        const { rippleExiting, rippleEntering } = this.state;

        return (
            <Transition
                onEnter={this.handleEnter}
                onExit={this.handleExit}
                timeout={timeout}
                {...other}
            >
                <span className={wrapperExiting ? 'ripple-exiting' : ''}>
                    <span 
                        className={rippleEntering ? 'ripple-entering' : ''}
                        style={{
                            width: rippleSize,
                            height: rippleSize,
                            top: rippleY - (rippleSize / 2),
                            left: rippleX - (rippleSize / 2),
                            backgroundColor: color,
                        }} 
                    />
                </span>
            </Transition>
        );
    }
}

Ripple принимает три важных реквизита: rippleX, rippleY (это позиция, в которой он должен появиться) и rippleSize, который указывает чистый объем компонента. Эти свойства вычисляются в компоненте RippleWrapper, поэтому далее мы поговорим о том, как их вычислить.

Вы определенно можете написать более надежный код, чем этот, но, как я упоминал ранее, я даю вам только суть или основную структуру моей реализации.

‹RippleWrapper›

Обработка событий

Сначала мы связываем интересующие нас события.

class RippleWrapper extends React.Component {
    state = { 
        rippleArray: [],
        nextKey: 0
    };
    
    handleMouseDown = (e) => { this.start(e); }
    handleMouseUp = (e) => { this.stop(e); }
    handleMouseLeave = (e) => { this.stop(e); }
    handleTouchStart = (e) => { this.start(e); }
    handleTouchEnd = (e) => { this.stop(e); }
    handleTouchMove = (e) => { this.stop(e); }

    render () {
        <TransitionGroup
            component="span"
            enter
            exit
            onMouseDown={this.handleMouseDown}
            onMouseUp={this.handleMouseUp}
            onMouseLeave={this.handleMouseLeave}
            onTouchStart={this.handleTouchStart}
            onTouchEnd={this.handleTouchEnd}
            onTouchMove={this.handleTouchMove}
        >
            {this.state.rippleArray}
        </TransitionGroup>
    }
}

Когда срабатывают два события mousedown или touchstart, нам нужно создать новую рябь. Итак, мы назовем this.start. Когда срабатывают остальные четыре события, это означает, что пульсация должна быть удалена, поэтому мы просто вызовем this.stop.

Некоторые из вас могут спросить: эти события (по крайней мере, некоторые из них) на самом деле запускаются компонентом Ripple, а не непосредственно RippleWrapper, собираемся ли мы их фиксировать? Да, из-за распространения событий. Событие сработает для предков целевого элемента в той же иерархии вложенности, поэтому обработчики событий, которые мы установили на RippleWrapper, в конечном итоге сработают, и все будет хорошо, не так ли. Вообще-то, нет. Использование всплывающих событий приводит к огромной, но очень сложной ошибке, которую мы обсудим позже.

start() и stop()

В функции start мы должны выполнить три задачи:

  1. Вычислите координаты того места, где именно происходит событие, и затем передайте их Ripple как rippleX и rippleY.
  2. Определите, насколько большой должна быть пульсация, и передайте ее Ripple как rippleSize.
  3. Создайте рябь, используя только что рассчитанные нами факторы.
function start (e) {
    const element = ReactDOM.findDOMNode(this);
    const rect = element
        ? element.getBoundingClientRect()
        : {
            left: 0,
            right: 0,
            width: 0,
            height: 0,
        };
    let rippleX, rippleY, rippleSize;
    // 1. calculate rippleX and rippleY
    const clientX = e.clientX ? e.clientX : e.touches[0].clientX;
    const clientY = e.clientY ? e.clientY : e.touches[0].clientY;
    rippleX = Math.round(clientX - rect.left);
    rippleY = Math.round(clientY - rect.top);
    // 2. calculate ripple size
    const sizeX = Math.max(Math.abs((element ? element.clientWidth : 0) - rippleX), rippleX) * 2 + 2;
    const sizeY = Math.max(Math.abs((element ? element.clientHeight : 0) - rippleY), rippleY) * 2 + 2;
    rippleSize = Math.sqrt(Math.pow(sizeX, 2) + Math.pow(sizeY, 2));
    // 3. create ripple
    this.createRipple({ rippleX, rippleY, rippleSize });
}

Обратите внимание, что для rippleX и rippleY нам нужны относительные координаты, поэтому значения e.clientX и e.clientY нельзя использовать напрямую, поскольку они являются« абсолютными координатами». Вместо этого мы вычитаем их на абсолютные координаты самого RippleWrapper. (мы можем получить эти координаты с помощью getBoundingClientRect)

По поводу stop сказать особо нечего. Единственное, что мы делаем, это удаляем первый элемент из rippleArray.

function stop (e) {
    const { rippleArray } = this.state;
    if (rippleArray && rippleArray.length) {
        this.setState({
            rippleArray: rippleArray.slice(1),
        });
    }
}

createRipple ()

Теперь мы можем, наконец, собрать их все вместе, создать компонент Ripple и добавить его в наш rippleArray.

function createRipple (params) {
    const { rippleX, rippleY, rippleSize } = params;
    let { rippleArray } = this.state

    rippleArray = [
        ...rippleArray,
        <Ripple 
            key={this.state.nextKey}
            rippleX={rippleX}
            rippleY={rippleY}
            rippleSize={rippleSize}
        />
    ];

    this.setState({
        rippleArray: rippleArray,
        nextKey: this.state.nextKey + 1,
    });
}

Примечание для nextKey здесь. React просит нас иметь уникальный ключ для каждого элемента списка, чтобы повысить эффективность согласования. Таким образом, мы будем увеличивать nextKey на 1 каждый раз, когда создадим рябь.

Букашка

Как я упоминал ранее, существует критическая ошибка, если вы полагаетесь на всплытие событий. То есть, когда вы щелкаете элемент с большей скоростью, событие «onclick» никогда не запускается.

Сначала я подумал, что это как-то связано с всплыванием событий, но позже, когда я провел некоторую отладку и обнаружил, что событие никогда не запускалось, я понял, что это поведение браузера, которое имеет какое-то отношение к механизму событий.

Без сомнения, я обратился к рабочему проекту w3, пытаясь понять, при каких условиях сработает событие onclick. И вот что нам дала w3:

Событию click МОГУТ предшествовать события mousedown и mouseup в том же элементе, без учета изменений между другими типами узлов (например, текстовыми узлами).

То есть, mousedown и mouseup должны запускаться для одного и того же элемента, прежде чем onclick запустится. И вот через что мы прошли, когда мы щелкаем элемент быстро , мы фактически запускаем mousedown на предыдущем Ripple, который вскоре после этого уничтожается , а затем mouseup срабатывает на текущем Ripple. Они срабатывали по разным элементам, и в результате onclick просто не запускался.

Что касается решения, эту проблему можно решить, добавив одну строку кода CSS в элемент Ripple:

pointer-events: none;

Почему это работает? Когда pointer-events установлен на none:

Элемент никогда не является целью событий указателя; однако события указателя могут быть нацелены на его дочерние элементы, если для этих потомков pointer-events установлено какое-то другое значение. В этих обстоятельствах события указателя будут запускать прослушиватели событий на этом родительском элементе по мере необходимости на их пути к / от потомка во время фаз захвата / пузыря события.

Следовательно, все события запускаются в компоненте RippleWrapper, и это нас полностью устраивает, поскольку мы все равно обрабатываем эти события на RippleWrapper.

Конец

Вот в основном то, как создать эффект ряби в материальном дизайне с помощью React. Но если вы реализуете это в реальном мире, есть еще кое-что, о чем вам следует позаботиться.

  • touch события иногда могут происходить невероятно быстро, что означает, что некоторые Ripple будут stop() еще до того, как они будут созданы и отображены. Поэтому необходимо добавить тайм-аут или дебаунс.
  • Событие touchstart начинается с mousedown. Таким образом, прикосновение к сенсорному устройству приведет к созданию двух Ripple. Чтобы исправить это, нам нужен флаг, чтобы игнорировать mousedown.

Вот и все! Конечно, есть некоторые детали, на которые вы должны обратить внимание, но я не буду останавливаться на них в этом посте. Если вам интересно, вы всегда можете обратиться к исходному коду на github. Оставьте звезду, если она вам нравится. Спасибо.