👨‍💻 Блог Анатолия Гуляева

Создаем компонент прокручиваемого контейнера на Реакте

Задача — требуется реализовать компонент-контейнер:

  • в который можно просто передать список карточек
  • который ограничен по ширине
  • который скроллится по горизонтали
  • который имеет базовые контролы — стрелочки влево и вправо

Будем учитывать, что:

  • Лучше реализовать свой компонент, чужие раздувают бандл
  • Есть реализация от TJ Fogarty, но она написана с использованием классовых компонентов, а так хочется хуки и Тайпскрипт. За основу я взял его реализацию.

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

В iframe компонент может работать не так, как надо. Если просколлить вправо до упора, кнопка и тень не исчезают. Лучше перейти в сам codesandbox. Пока думаю, как исправить.

Наивная реализация

Краткий план:

  • Создать контейнер с вертикальным скроллом
  • Расположить внутрь dummy карточки
  • Добавить стили, чтобы работала горизонтальная прокрутка

Создадим файл компонента scrollable-container.tsx. Структура: обёртка — обычный div, внутри обёртки ненумерованный список ul с элементами li.

export const ScrollableContainer = () => {
  return (
    <div className="scrollableContainer">
      <ul className="list">
        <li className="item">1</li>
        <li className="item">2</li>
        <li className="item">3</li>
        <li className="item">4</li>
        <li className="item">5</li>
        <li className="item">6</li>
        <li className="item">7</li>
      </ul>
    </div>
  );
};

Для дива-обёртки назначим относительную позицию, чтобы потом расставить абсолютно кнопки по бокам. Список у нас будет флексовым, добавим overflow-x: auto, чтобы он скроллился по горизонтали, если элементы не влезут. Сбросим дефолтные стили у списка. Элементам-карточкам зададим базовые стили, чтобы смотрелись красиво и зададим важное свойство flex-shrink: 0, чтобы карточки не сжимались внутри списка.

.scrollableContainer {
  position: relative;
}

.list {
  display: flex;
  overflow-x: auto;
  list-style: none;
  padding: 0;
  margin: 0;
}

.item {
  display: flex;
  width: 12.5rem;
  height: 12.5rem;
  background-color: aquamarine;
  border-radius: 8px;
  justify-content: center;
  align-items: center;
  font-size: 2rem;
  flex-shrink: 0;
}

.item:not(:last-child) {
  margin-right: 1em;
}

Также не забудем ограничить ширину контейнера страницы:

.App {
  font-family: sans-serif;
  text-align: center;
  max-width: 540px;
  margin: 0 auto;
}

Уже в принципе неплохо выглядит и работает. Для чисто мобильного сайта пойдёт, но на десктопе будет неочевидно, что контейнер можно скроллить. Поэтому добавим кнопки.

Определение возможности скролла и добавление кнопок

Что сделаем?

  • Создадим состояния для определения возможности скролла влево или вправо — canScrollLeft и canScrollRight
  • С помощью хука useRef получим доступ к свойствам списка
  • Отследим свойства списка, который содержит контент
    • Свойства
      • scrollLeft — количество пикселей, на которое прокручен контент списка
      • scrollWidth — ширина контента с учетом горизонтальной прокрутки
      • clientWidth — ширина списка без учета горизонтальной прокрутки
    • Если scrollLeft больше нуля, то можно скроллить влево. Если упремся в левую стенку scrollLeft будет равен нулю.
    • Если scrollLeft не равен разности scrollWidth и clientWidth, то можно скроллить вправо. Если упремся в правую стенку scrollLeft равен scrollWidth - clientWidth.
  • Обернем функцию checkForScrollPosition в debounce. Так функция checkForScrollPosition будет вызыватся не более одного раза в 200 миллисекунд.
  • Создадим функцию scrollContainerBy с одним аргументом — количество пикселей, на которое будем скроллить список
  • Создадим эффект, внутри которого навесим обработку события scroll на список, а еще будем проверять возможность скролла. Также не забудем сбросить эффект.
  • Отобразим кнопки с учетом возможности скролла. Обработаем событие onClick, передав функцию scrollContainerBy. Если скроллить нельзя, то кнопка становится неактивной.

Файл scrollable-container.tsx:

import { useState, useRef, useEffect } from "react";
import debounce from "lodash.debounce";

export const ScrollableContainer = () => {
  const [canScrollLeft, setCanScrollLeft] = useState<boolean>(false);
  const [canScrollRight, setCanScrollRight] = useState<boolean>(false);

  const listRef = useRef<HTMLUListElement>(null);

  const checkForScrollPosition = () => {
    const { current } = listRef;
    if (current) {
      const { scrollLeft, scrollWidth, clientWidth } = current;
      setCanScrollLeft(scrollLeft > 0);
      setCanScrollRight(scrollLeft !== scrollWidth - clientWidth);
    }
  };

  const debounceCheckForScrollPosition = debounce(checkForScrollPosition, 200);

  const scrollContainerBy = (distance: number) =>
    listRef.current?.scrollBy({ left: distance, behavior: "smooth" });

  useEffect(() => {
    const { current } = listRef;
    checkForScrollPosition();
    current?.addEventListener("scroll", debounceCheckForScrollPosition);

    return () => {
      current?.removeEventListener("scroll", debounceCheckForScrollPosition);
      debounceCheckForScrollPosition.cancel();
    };
  }, []);

  return (
    <div className="scrollableContainer">
      <ul className="list" ref={listRef}>
        <li className="item">1</li>
        <li className="item">2</li>
        <li className="item">3</li>
        <li className="item">4</li>
        <li className="item">5</li>
        <li className="item">6</li>
        <li className="item">7</li>
      </ul>
      <button
        type="button"
        disabled={!canScrollLeft}
        onClick={() => scrollContainerBy(-400)}
      >
        ←
      </button>
      <button
        type="button"
        disabled={!canScrollRight}
        onClick={() => scrollContainerBy(400)}
      >
        →
      </button>
    </div>
  );
};

Теперь можно скроллить с помощью кнопок.

Последние штрихи

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

Файл scrollable-container.tsx:

import { useState, useRef, useEffect } from "react";
import cn from "classnames";
import debounce from "lodash.debounce";

export const ScrollableContainer = () => {
  const [canScrollLeft, setCanScrollLeft] = useState<boolean>(false);
  ...

  return (
    <div className="scrollableContainer">
      <ul className="list" ref={containerRef}>
        ...
      </ul>
      <button
        type="button"
        disabled={!canScrollLeft}
        onClick={() => scrollContainerBy(-400)}
        className={cn("button", "buttonLeft", {
          "button--hidden": !canScrollLeft
        })}
      >
        ←
      </button>
      <button
        type="button"
        disabled={!canScrollRight}
        onClick={() => scrollContainerBy(400)}
        className={cn("button", "buttonRight", {
          "button--hidden": !canScrollRight
        })}
      >
        →
      </button>
      {canScrollLeft ? (
        <div className="shadowWrapper leftShadowWrapper">
          <div className="shadow leftShadow" />
        </div>
      ) : null}
      {canScrollRight ? (
        <div className="shadowWrapper rightShadowWrapper">
          <div className="shadow rightShadow" />
        </div>
      ) : null}
    </div>
  );
};

Файл styles.css:

.button {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: none;
  background-color: #fff;
  box-shadow: 0 4px 8px rgb(0 0 0 / 20%);
  font-size: 1.2rem;
  cursor: pointer;
  z-index: 2;
}

.buttonLeft {
  left: -20px;
}

.buttonRight {
  right: -20px;
}

.button--hidden {
  display: none;
}

.shadowWrapper {
  width: 30px;
  height: 100%;
  overflow: hidden;
  z-index: 1;
  position: absolute;
  top: 0;
}

.leftShadowWrapper {
  left: 0;
}

.rightShadowWrapper {
  right: 0;
}

.shadow {
  position: absolute;
  box-shadow: 0 0 30px -8px #232628;
  position: absolute;
  z-index: 1;
  top: 50%;
  right: -25px;
  width: 24px;
  height: 80%;
  border-radius: 50%;
  transform: translateY(-50%);
}

.leftShadow {
  box-shadow: 0 0 30px -8px #232628;
  left: -25px;
}

.rightShadow {
  box-shadow: 0 0 30px -8px #232628;
  right: -25px;
}

Итоговый вариант:

Что можно сделать лучше

  • Скрыть скроллбар
  • Прокинуть элементы-карточки через проп
  • Добавить свойства scroll-snap-type и scroll-snap-align

Ссылки


Мои закладки — 12

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

  1. История фронтенда. Браузер, который умел всё.. Первый выпуск видеосерии от CSSSR про историю фронтенда. Снято потрясно.
  2. How I cut GTA Online loading times by 70%. Программисту с никнеймом tostercx удалось сократить загрузку GTA Online с 6 минут до почти 2 минут с помощью реверс-инжиниринга.
  3. Keyboard-Only Focus. На работе потребовалось реализовать фокус для кнопок только с клавиатуры в Хроме, но я удивился, что это не так просто и есть нюансы.
  4. Healthy Sitting Posture to Reduce Back Pain. А как сидеть?
  5. What I do to break down large projects into small actionable steps. О декомпозиции больших и неопределённых задач типа “Сделать сайт-портфолио” на мелкие и осмысленные шаги.
  6. Announcing “use-sound”, a React Hook for Sound Effects. Хук для Реакта для воспроизведения звуков.
  7. BOOKSTORES: How to Read More Books in the Golden Age of Content. Офигенный фильм про то, как читать больше книг в эру Твиттера. Спойлер: полчаса чтения в день ≈ 18 книг в год.
  8. How to avoid layout shifts caused by web fonts. Статья помогла решить проблему со сдвигом макета во время загрузки гугловских шрифтов. Иногда достаточно свойства font-display: optional, но есть крайные случаи, которые разобраны в статье.
  9. Styled Range Input - A way out of Range Input nightmare. Статья помогла мне сверстать input type="range" в Хроме так, чтобы часть до и после ползунка были окрашены в разные цвета.
  10. The context dilemma: design tokens and components. Статья, которая навела меня на решение проблемы нейминга дизайн-токенов в рабочем проекте.
  11. Персональный репозиторий знаний. Выпуск подкаста Подлодки про ведение заметок, Зеттелькастены и Роам Рисёрч.
  12. Возьми и сделай. В голове возникла идея? Возьми и реализуй. Хоть из говна и палок, но реализуй.
  13. Just how bad is The Combine?. Анализ кровавого режима Альянса из Half-Life 2. Узнал много нового о лоре игры. Например, семьи ГО-шников Альянс держит в заложниках, чтобы в случае неповиновения убить их или, я думаю, превратить в сталкеров.
Скриншот из игры Half-Life 2.

Одной строкой

  1. Digital Tools I Wish Existed.
  2. Create Responsive Image Effects With CSS Gradients And aspect-ratio.
  3. Клипового мышления не существует.
  4. Зарплата в 200 долларов и никаких перспектив: почему аниматорам в Японии так мало платят.
  5. Сразу к делу, а не «Перейти».

Мои закладки — 11

Первый пост, который написан полностью на Айпаде (2020).

  1. Избегайте новостей. Здесь и далее мой перевод статьи Avoid… | by Danila | Medium. Стараюсь не читать новости. И вам советую.
  2. How Writing Online Made me a Millionaire. Самый продуктивный ютьюбер рассказывает, как стал миллионером будучи блогером. Ключевая мысль – не оверсинкать и просто писать, не боясь осуждения (это, конечно, не сделает вас обязательно миллионером, но мысль правильная).
  3. I don’t want to do front-end anymore | Lobsters. Комментарий с Лобстерс на нашумевшую статью I don’t want to do front-end anymore. Учите то, что не меняется быстро: алгоритмы, БД, софт скиллы. Беритесь за то, что боятся браться другие.
  4. The web didn’t change; you did. Ответочка на предыдущую ссылку о том, что веб не изменился. Ничего не мешает вам заюзать Джейквери и обмазаться ПХП, как олды.
  5. Ray.so - Create beautiful code. Инструмент для создания красивых изображений кода.
Скриншот js-кода, сделанный в программе ray.so.

Мои закладки — 10

Мне не понравился Рэйндроп. Не могу в данный момент его рекомендовать для организации закладок. Приложение очень забагованное. Вчера я не смог перенести закладку из одной папки в другую. Написал на прошлой неделе отзыв в Эпп Стор:

Вроде бы норм, а вроде и нет

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

Косяки:

  1. В папке 140 закладок, но отображаются только 40. Перезапуск не помогает. Это происходит, если сортировка стоит в режиме “Вручную”.
  2. Хочу переместить закладку, которая находится в середине списка, в другую папку, кликаю на три точки, выбираю папку, она переносится. Вроде бы ок, но после всего этого зачем меня надо перемещать в начало списка. Мешает работать.
  3. Отметил закладку галочкой, а кнопка “Отмена” не работает.

Закладки:

  1. Заметки Энди Матущака. Вечнозелёные заметки Энди Матущака — экс-инженера Эппл. Можно надолго залипнуть.
  2. WASM + React… Easily build video editing software with JS & FFmpeg. Простой туториал, с помощью которого вы сможете написать конвертер видео в гиф на Реакте и WASM.
  3. no hello. Избавляюсь от тупой привычки писать в чат просто “Привет)”.
  4. Hide Dock Wallpapers. Нескучные обои, которые умеют скрывать док и папки, для Айфонов.
  5. conwnet / github1s . Добавьте 1s после github, нажмите Enter. Вау! Гитхаб репозиторий открылся в VSCode, прямо в брузере.
  6. Craft. Нативный аналог Ноушн для яблочных устройств.
  7. Remotion. Библиотека для создания моушн графики в Реакте!
  8. Твит про контент, который теперь не хранится. Не понимаю хайпа вокруг Клабхауса.
  9. This Productivity System Will Save Your Life. Мэтт Д’Авелла о полезности чек-листов.
  10. iPhone 12 Pro Cinematic 4K: New York City Snowstorm 2021 | Dolby Vision | HDR. Видео снято на Айфон 12 Про. Красиво!

Обновление от 18.02.2021: Исправил опечатку.


Мои закладки — 9

Первый дайджест в 2021 году. И теперь я понял, как разбирать закладки так, чтобы выпуски выходили почаще. Хотелось бы раз в неделю. Так вот, я сохряняю всё интересное найденное в сети в программу Рэйндроп. Раньше использовал Пинбоард, какие-то обскьюрные селф-хостед решения и Ноушн. Рэйндроп подкупил даже не знаю чем. Ноушн мне не нравится своими долгими загрузками, Пинбоард — дороговато для меня, чтобы просто хранить закладки. Наткнулся на Рэйндроп, дальше вы, наверное, сами знаете: миграция с одного продукта на другой — это святое.

Разработал свой минимальный воркфлоу. Сохраняю ссылку, если не лень, то ставлю теги, ссылка попадает в папку «Несортированные». В конце недели сажусь делать рутину выходного дня. Во время этой рутины проставляю недостающие теги к ссылке, что-то удаляю, то, что осталось перемещаю в папку «to-read». Потом, когда будет время просматриваю папку «to-read», если цепляет, то пишу конспект в Обсидиан, переношу ссылку в папку «read». Теперь у меня есть закладки, которые можно публиковать в дайджест. Их я потом переношу в папку «read & published».

С таким в воркфлоу можно публиковать дайджесты регулярнее и без затыков.

  1. Волшебная таблетка для дизайнеров и редакторов. Про то, как развить в себе талант и разбогатеть.
  2. As someone who listens to spotify all day while programming, here’s my secret to success. Чувак делится способом, как собрать плейлист в Спотифае для программирования.
  3. How to Favicon in 2021: Six files that fit most needs. Статья Андрея Ситника про то, как организовать фавиконки. Не пользуйтесь генераторами иконок — они плодят кучу устаревших и ненужных иконок. Реализовал в рабочем проекте. Рекомендую.
  4. 100 Tips for a Better Life. 100 советов для лучшей жизни. Парочку взял на вооружение.
  5. 電磁祭囃子 in NEO TOKYO 2020. Именно так представляли музыку будущего люди из 90-х.
Скриншот из последней ссылки. Здесь группа из Японии играет на самодельных инструментах электронную музыку. Инструменты сделаны, например, из старых телевизоров.