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

от автора

Всем привет, сегодня я расскажу вам как разрабатывал кнопку для 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 репозиторий

ссылка на оригинал статьи https://habr.com/ru/post/484554/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *