Pull to refresh

Создаем кнопку с Ripple Effect для XMars UI

Reading time9 min
Views6.9K


Всем привет, сегодня я расскажу вам как разрабатывал кнопку для XMars UI проекта. О да, вроде мелочь, но есть о чем рассказать. Я опущу детали которые связаны с добавлением нового компонента в опенсорс проект. Более детально я расскажу про проект в отдельной статье.


Введение


XMars UI — это один из моих новых опенсорс проектов. Простая библиотека UI компонентов под HTML / CSS и React. В будущем планирую поддерживать Vue. Пока в ней только кнопка и иконки :)


Проект родился как идея в рамках Telegram Contest, cуть которого заключалась в разработке веб версии клиента. Вместе с коллегой мы решили, а почему бы и не принять в этом участие. Роли поделились так, что на мне верстка, а когда коллега разберется с авторизацией, то я подключусь писать компоненты. Все бы хорошо, но авторизоваться в Телеграмме не так просто. В итоге мы нечего не отправили, а я наверстал кучу всего и выходит — зря. Но как говорит Варламов, ваш проект уже чего-то стоит, раз вы потратили на него свое время. С этим сложно не согласится, ведь если переводить на часы и денежки, то только настройка Webpack в самом начале проекта уже не бесплатно. Смотря на все это безобразия, решил надо как-то выкинуть на опенсорс. Что один бутстрап использовать? Хочется свой UI фреймворк под другие свои проекты.


The Button


Кнопка в интерфейсе — пожалуй главный элемент с помощью которого пользователь взаимодействует с приложением. Следовательно, это один из первых компонентов любого UI фреймворка / библиотеки.


В дизайне Телеграм, не так много вариаций кнопок:



Я выделил 3 основных (default, accent, primary), круглую с иконкой и зеленую. Есть еще полу прозрачная, но опустим ее. По большей части разрабатывая XMars UI я стараюсь исходить из потребностей, не придумал куда бы понадобилась прозрачная кнопка.


Пользователю библиотеки должно быть удобно использовать CSS классы. Я не фанат таких систем нейминга как БЭМ. Мне больше нравится, то как Bootstrap задает имена классам. Но я бы упростил еще немного. Вместо .btn .btn-primary — просто .btn .primary. А в случае с React компонентом, будет выглядеть так:


<Button primary>Hey</Button>

Такая же кнопка но ripple effect:


<Button primary ripple>Hey</Button>

HTML / CSS


UI библиотека не должна быть привязана к какому-либо UI фреймворку. В будущем планирую натянуть верстку и на Vue компоненты. По этому начнем с простого HTML / CSS.


Под капотом у проекта Tailwindcss, это utility-first CSS framework, то есть фреймворк который предоставляет вам утилиты, вместо полноценных компонентов.



Помимо Tailwindcss, используется PostCSS для миксинов, переменных и вложенных стилей


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


Тег <button> имеет ряд дефолтных стилей которые нам необходимо либо убрать, либо переопределить.



В случае с Tailwindcss, тег кнопки имеет такой стиль:



Все лишнее по умолчанию убрано. Можно лепить, что хочешь не боясь, что на каком-то состоянии выпадет дефолтный бордер. Но тут же оговорочка, дефолтный outline все таки нужно прибить:



Кнопка в XMars UI имеет класс .btn:


<button class="btn">Button</button>

Добавляем этот класс в наши стили:


.btn {
    @apply text-black text-base leading-snug; 
    @apply py-3 px-4 border-none rounded-lg;
    @apply inline-block cursor-pointer no-underline;

    &:focus {
        @apply outline-none;
    }
}

Помимо того, что Tailwindcss предоставляет классы которые вы можете использовать, он предоставляет своего рода mixins. @apply это не SCSS или какой-то плагин под PostCSS. Это синтаксис самого Tailwindcss. Стили, которые применяются в целом семантически понятны из названия. Единственно py-3 и px-4 могут вызывать вопросы. Первый это padding по y, то есть по вертикали, а именно — padding-top: 0.75rem; padding-bottom: 0.75rem;. Следовательно, px-4 по горизонтали — padding-right: 1rem;, padding-left: 1rem;.


Дизайн, который предоставил Телегерамм мягко говоря плохо задокументирован и такие вещи как border-radius кнопки приходится брать линейкой прямо из изображения. Когда нибудь задумывались, что именно означанют значения в border-radius?



Это буквально радиус получаемого круга в угле. Если по колхозу, то можно изменить линейкой как показано на картинке выше. Так я и сделал используя прямоугольное выделение в Gimp.



border-radius у кнопок в дизайне равен 10px, к сожалению такого класса из коробки в Tailwindcss нет, но мне визуально хватило rounded-lg который равен 8px при дефолтном размере шрифта (rem).


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



Далее нам необходимо сделать эффект на :hover. Тут дизайнеры из Телеграмм решили пролить немного света и указали цвет как 0.08% от #707579. Я вижу два варианта, просто взять цвет пипеткой или же сделать как задокументировано. Первый вариант проще, но на прспективу не самый хороший. Дело в том, что если задний фон будет отличаться от белого, то на :hover мы будем получать конкретный цвет, терять "легкость" и прозрачность кнопки. По этому лучше последовать документации и заложить альфа самца канал. Сделать это можно бесчисленным количеством способов, например использовать SCSS функции по работе с цветом. Но в проекте нет SCSS, а из за одного цвета подключать какой-то плагин к PostCSS не хочется, сделаем все очень просто. В Chrome, есть колопикер который позволяет трансформировать цвета в разные системы, вбиваем туда HEX цвета #707579, переводим в rgba и задаем альфа канал — 0.08%.



Вуаля! Что-то у меня резко флешбэчнула картинка:



Получаем — rgba(112, 117, 121, 0.08).



(:hover)


Далее скучно и без особых усилий, я добавил остальные состояния:


    &:hover {
        background-color: var(--grey04);
    }

    &.accent {
        color: var(--blue01);
    }

    &.primary {
        @apply text-white;

        background-color: var(--blue01);

        &:hover {
            background-color: var(--blue02);
        }
    }

React компонент


Изначально, кнопка версталась под контест Телеграмма и использовать какой-либо фреймворк было нельзя. Пришлось, реализовал ripple effect на чистом JS. Мне бы очень хотелось, что бы так и осталось, но пока проектом занимаешься в одиночку, приходится чем-то жертвовать.


Компоненты, которые требуют какой-либо логики, например такой, как ripple effect, будут реализованы и доступны только в виде React компонентов.


Завернуть кнопку в React компонент особого труда не составляет:


import React, { FunctionComponent } from 'react';

export interface ButtonProps {
}

const Button: FunctionComponent<ButtonProps> = (props) => {
    return (
        <button className="btn">props.children</button>
    );
}

export default Button;

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


Для того, что бы пользователь мог передать все необходимое, для начала нужно побороть Typescript, иначе даже onClick не даст нормально передать. Немного подредактировав интерфейс ButtonProps, решаем проблему:


export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>

после чего мы можем смело делать деструкцию props:


<button className="btn" {...props}>props.children</button>

Подобное использование кнопки будет вести себя как ожидается:


<Button onClick={() => alert()}>Hey</Button>


Далее добавим стили кнопки и возможность прописывать кастомный (вдруг кому-то понадобится) класс. Для этих целей отлично подойдет npm пакет classnames.


export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
    primary?: boolean,
    accent?: boolean,
    additionalClass?: string,
}

...

const classNames = classnames(
        'btn',
        {
            primary
        },
        {
            accent
        },
        additionalClass
    );

...

<button className={classNames} {...props}>props.children</button>

Класс btn устанавливается всегда, а вот primary и accent только если равны true. Classnames добавляет класс, если в значении у него логическое true, используя сокращение из ES6 получается простая запись { primary }, вместо { primary: true }.


additionalClass — строка, и если она будет пустая или undefined, для нас особой роли не играет, просто к элементу нечего не добавится.


По началу я присваивал props следующим образом:


{...omit(props, ['additionalClass', 'primary'])}

Опуская, все, что не относится к props элемента кнопки, но в этом нет необходимости так как React не отренедрит лишнее.


Ripple Effect



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


Но погуглив, посмотрев примеры на codepen, стало понятно, что в большинстве случаев, реализуется "волна" через дочерний элемент, который расширяется и пропадает.


Позиционируется он внутри кнопки по координатам клика. В XMars UI на данный момент я решил не реализовать данный эффект на onPress как это делает Material UI, но в будущем планирую доработать. Пока только на onClick.



На картинке выше вся магия. На клик создается дочерний элемент кнопки, позиционируется абсолютно, по центру клика и расширяется. Свойство overflow: hidden, не дает "волне" выйти за пределы кнопки. Элемент необходимо удалить по окончанию анимации.


Сначала определим стили, где можно, по максимуму используем Tailwindcss:


.with-ripple {
        @apply relative overflow-hidden;

        @keyframes ripple {
            to {
                @apply opacity-0;
                transform: scale(2.5);
            }
        }

        .ripple {
            @apply absolute;

            z-index: 1;
            border-radius: 50%;
            background-color: var(--grey04);
            transform: scale(0);
            animation: ripple 0.6s linear;
        }

        &.primary {
            .ripple {
                background-color: var(--black02);
            }
        }
    }

Элементу отвечающему за эффект будет присвоен класс .ripple. border-radius: 50%; равняется кругу (по 50% скругления на угол * 2), у кнопки позиционирование относительное, у .ripple — абсолютное кнопке. Анимация очень простая, "волна" увеличиваясь становится прозрачной за 0.6 секунды. Цвет фона такой же как :hover и складываясь, два прозрачных цвета "волны" и кнопки дают нам желаемый результат. На синей .primary кнопке это уже не так принципиально и там можно использовать не прозрачный цвет.


На клик необходимо создавать элемент "волны". Поэтому создаем под это дело стейт и добавляем кнопке соответствующий обработчик клика, но таким образом, что бы он не мешал пользовательскому onClick.


...

    const [rippleElements, setRippleElements] = useState<JSX.Element[]>([]);

...

    function renderRippleElements() {
        return rippleElements;
    }

    return (
        <button
            className={classNames}
            {...props}
            onClick={(event) => {
                if (props.onClick) {
                    props.onClick(event);
                }

                if (ripple) {
                   onRippleClick(event);
                }
            }}
        >
            {children}
            {renderRippleElements()}
        </button>
    );

rippleElements — массив JSX элементов, функция рендера тут может показаться излишней, но это больше дело стиля и заделки на будущее.


   function onRippleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
        var rect = event.currentTarget.getBoundingClientRect();

        const d = Math.max(event.currentTarget.clientWidth, event.currentTarget.clientHeight);
        const left = event.clientX - rect.left - d/2 + 'px';
        const top = event.clientY - rect.top - d/2 + 'px';
        const rippleElement = newRippleElement(d, left, top);

        setRippleElements([...rippleElements, rippleElement]);
    }

    function newRippleElement(d: number, left: string, top: string) {
        const key = uuid();

        return (
            <div
                key={key}
                className="ripple"
                style={{width: d, height: d, left, top}}
                onAnimationEnd={() => onAnimationEnd(key)}
            >
            </div>
        );
    }

onRippleClick обработчик которыый создает "волны". По клику на кнопке, мы узнаем размеры кнопки, которые используются для правильного позиционирования круга, после чего все необходимое передается в функцию newRippleElement которая в свою очередь просто создает div элемент с классом ripple, здавая необходимые стили для позиционирования.


Из главных вещей стоит выделить onAnimationEnd. Данный ивент нам необходим для отчистки DOM от уже отработавших элементов.


    function onAnimationEnd(key: string) {
        setRippleElements(rippleElements => rippleElements.filter(element => element.key !== key));
    }

Очень важно не забыть, передать в аргументы текущие rippleElements, иначе можно получить массив со старыми значениями, и все будет работать не так как задумано.


Полный код кнопки:


import React, { FunctionComponent, ButtonHTMLAttributes, useState } from 'react';
import uuid from 'uuid/v4';
import classnames from 'classnames';

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
    primary?: boolean,
    accent?: boolean,
    circle?: boolean,
    ripple?: boolean,
    additionalClass?: string,
}

const Button: FunctionComponent<ButtonProps> = (props) => {
    const [rippleElements, setRippleElements] = useState<JSX.Element[]>([]);
    const {primary, accent, circle, ripple, additionalClass, children} = props;

    const classNames = classnames(
        'btn',
        {
            primary
        },
        {
            'with-ripple': ripple
        },
        {
            circle
        },
        {
            accent
        },
        additionalClass
    );

    function onAnimationEnd(key: string) {
        setRippleElements(rippleElements => rippleElements.filter(element => element.key !== key));
    }

    function onRippleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
        var rect = event.currentTarget.getBoundingClientRect();

        const d = Math.max(event.currentTarget.clientWidth, event.currentTarget.clientHeight);
        const left = event.clientX - rect.left - d/2 + 'px';
        const top = event.clientY - rect.top - d/2 + 'px';
        const rippleElement = newRippleElement(d, left, top);

        setRippleElements([...rippleElements, rippleElement]);
    }

    function newRippleElement(d: number, left: string, top: string) {
        const key = uuid();

        return (
            <div
                key={key}
                className="ripple"
                style={{width: d, height: d, left, top}}
                onAnimationEnd={() => onAnimationEnd(key)}
            >
            </div>
        );
    }

    function renderRippleElements() {
        return rippleElements;
    }

    return (
        <button
            className={classNames}
            {...props}
            onClick={(event) => {
                if (props.onClick) {
                    props.onClick(event);
                }

                if (ripple) {
                   onRippleClick(event);
                }
            }}
        >
            {children}
            {renderRippleElements()}
        </button>
    );
}

export default Button;

Итоговый результат можно поклацать здесь


Заключение


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


XMars UI Github репозиторий

Tags:
Hubs:
Total votes 7: ↑4 and ↓3+1
Comments1

Articles