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

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

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

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

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

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

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

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

Создадим файл компонента 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;
}

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

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

Что сделаем?

Файл 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;
}

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

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

Ссылки

Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter

Комментарии