Демо

Мотивация

В одном из моих проектов широко используются карусели. До сих пор использовался внешний пакет карусели npm. Хотя я ничего не имею против таких пакетов, наши варианты использования были очень специфичны. Карусели из внешних пакетов стараются обрабатывать все случаи, поэтому весят намного больше. Моя цель состояла в том, чтобы создать карусель, которая соответствует нашим потребностям и очень мала по размеру на диске.

Что мы будем строить?

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

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

Что нам понадобится?

Мы будем использовать React (с TypeScript) и стилизованные компоненты для достижения нашей цели.

Подготовка

Вы можете использовать npm или пряжу. Единственная причина использования npm в этом руководстве — простота.
Сначала мы создадим проект React и установим styled-components:

npx create-react-app carousels --template typescript
cd carousels
npm i styled-components
npm start

Карусель

Перемещения

Имея дело с такими каруселями, мы должны знать, что у них четыре хода. Если пользователь хочет увидеть следующее изображение, карусель либо покажет следующее (следующая смена), либо должна будет показать первое, когда больше нет изображений (следующий переход). Та же ситуация происходит и в обратном направлении, что дает нам еще два хода: предыдущий сдвиг и предыдущий прыжок.

Чтобы определить, была ли смена следующей или предыдущей сменой, нам нужно вычесть текущую позицию из предыдущей. Если число положительное, у нас следующая смена. Если число отрицательное, у нас есть предыдущий сдвиг. Ситуация обратная, если мы сталкиваемся со скачками. Поэтому, чтобы иметь всю информацию, нам нужно знать три вещи: предыдущее положение, текущее положение и направление.

Механика

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

Нам нужно рассмотреть видимое в данный момент изображение и следующее изображение, которое будет показано. Текущее видимое изображение должно будет сместиться (установить) от текущей позиции. Предстоящее изображение должно будет переместиться в текущую позицию (подъем).

Текущее видимое изображение будет сдвигаться влево или вправо в зависимости от того, хочет ли пользователь увидеть предыдущее или следующее изображение. Это дает нам две анимации: nextSet и previousSet. Предстоящее изображение будет скользить в представлении справа или слева в зависимости от того, хочет ли пользователь видеть предыдущее или следующее изображение. Это дает нам еще две анимации: следующий подъем и предыдущий подъем.

Пояснение с кодом

Штат

Как объяснялось ранее в этой статье, нам понадобится состояние, которое сохраняет значения предыдущее, текущее и направление. Мы будем использовать хук useState, который будет содержать массив. Массив состоит из трех чисел: [текущий, предыдущий, направление]. Текущее и предыдущее значения могут иметь любое числовое значение, представляющее позицию в массиве слайдов. Последнее число — это значение направления, которое мы установим на -1, 0 или 1. -1 означает перемещение назад, 1 вперед, а 0 — исходное состояние.

const [shift, setShift] = useState([0, 0, 0]);

Обработчик изменений

Когда пользователь хочет увидеть другой слайд

  • Мы должны вычислить позицию слайда, который становится текущим, и установить его в первый элемент в нашем массиве (shift[0])
  • Мы должны переместить текущий слайд на вторую позицию, поскольку он становится предыдущим (shift[1])
  • Элемент, который раньше был предыдущим, отбрасывается
  • Отмечаем направление движения и задаем его в нашем массиве (shift[2])
  • Мы должны помнить, что после последнего слайда мы показываем первый слайд при движении вперед и что после первого слайда мы показываем последний слайд при движении назад.

Все эти манипуляции будут отражены в наших анимациях/движениях.

type SlideShiftDirection = -1 | 0 | 1;

const shiftPosition = (direction: SlideShiftDirection) => {
  const nextShift = Math.abs(count + shift[0] + direction) % count;
  setShift([nextShift].concat(shift).slice(0, 2).concat([direction]));
};

Анимации

Имена анимации состоят из двух частей:

  • направление — в зависимости от действий пользователя: пользователь хочет видеть следующий слайд — следующий, пользователь хочет видеть предыдущий предыдущий слайд — предыдущий
  • поведение — устанавливается, когда слайд выходит из представления и поднимается, когда входит в представление

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

const voidAnimation = keyframes``;

const nextRise = keyframes`
  0% { transform: translateX(100%); }
  100% { transform: translateX(0); }
`;

const nextSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(-100%); }
`;

const previousRise = keyframes`
  0% { transform: translateX(-100%); }
  100% { transform: translateX(0); }
`;

const previousSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(100%); }
`;

nextRise — пользователь хочет увидеть следующий слайд. Предстоящий слайд перемещается справа налево и оказывается в поле зрения
nextSet — пользователь хочет увидеть следующий слайд. Текущий слайд перемещается справа налево и выходит за пределы представления
previousRise — пользователь хочет увидеть предыдущий слайд. Предстоящий слайд перемещается слева направо и оказывается в поле зрения
previousSet — пользователь хочет увидеть предыдущий слайд. Текущий слайд перемещается слева направо, заканчиваясь вне поля зрения

Структура

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

<Carousel>
  <Slides shift={shift} slideTime={slideTime}>
    {children}
  </Slides>
  {/* controls */}
</Carousel>

Компонент Carousel — это основная оболочка для слайдов и элементов управления карусели. Он также может содержать другие элементы карусели, например, точки прогресса.

Компонент Slides — основная рабочая лошадка карусели. Давайте обсудим это подробно.

const Slides = styled.section<{ shift: number[]; slideTime: number }>`
  overflow: hidden;
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
  & > * {
    z-index: -1;
    grid-row: 1;
    grid-column: 1;
  }
  ${({ shift, slideTime }) => [
    css`
      & > *:not(:nth-child(${1 + shift[0]})):not(:nth-child(${1 + shift[1]})) {
        display: none;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[0]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextRise
            : previousRise}
          forwards;
        z-index: 1;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[1]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextSet
            : previousSet}
          forwards;
        z-index: 0;
      }
    `,
  ]}
`;

Переполнение

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

Элементы стека

display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
& > * {
  z-index: -1;
  grid-row: 1;
  grid-column: 1;
 }

Этот фрагмент кода создает сетку с одной ячейкой. Все элементы вынуждены занимать эту конкретную ячейку.

Этот подход к стекингу проще в управлении и более интуитивно понятен по сравнению с использованием position: absolute. Слайды, у которых есть размеры, могут растягивать свой контейнер, а не быть просто один поверх другого, когда их контейнер не получает никаких размеров.

Анимация — синтаксис

Разберем синтаксис анимации.

Открываем выражение. Внутри него мы размещаем нашу функцию, которая будет принимать реквизиты и обрабатывать стили. Мы вернем массив из этой функции. Массив будет содержать правила css`` для применения на основе свойств. styled-components может применять все стили из списка.

${}
${(p) => { /* do something with props */ }}
${({ shift, slideTime }) => { /* do something with props */ } }
${({ shift, slideTime }) => []}
${({ shift, slideTime }) => [css``, css``, css``]

В нашей карусели есть три правила css``, так что давайте обсудим, что они делают.

css`
  & > *:not(:nth-child(${1 + shift[0]})):not(:nth-child(${1 + shift[1]})) {
    display: none;
  }
`

1-й. Этот фрагмент кода говорит, что все изображения в стеке скрыты, кроме видимого в данный момент и предыдущего. Предыдущее также не видно пользователю из-за того, что наша сетка имеет свойство overflow: hidden и предыдущее изображение было сдвинуто с помощью translateX. Такой подход позволяет использовать ленивую загрузку в таких фреймворках, как NextJS. Например, среда NextJS не загружает невидимые изображения (display: none) с помощью встроенного компонента Image. Это эффективно делает наши карусельные изображения ленивой загрузки. Также важно отметить, что целевыми являются только те слайды, которые не должны отображаться. Мы не устанавливаем и не изменяем свойство display ни одного из видимых слайдов. Часть 1 + in 1 + shift[0] исходит из того факта, что массивы отсчитываются от 0, а узлы DOM отсчитываются от 1.

css`
  & > *:nth-child(${1 + shift[0]}) {
    animation: ${slideTime}ms ease-out
      ${shift[2] === 0
        ? voidAnimation
        : shift[2] === 1
        ? rightRise
        : leftRise}
      forwards;
    z-index: 1;
  }
`,

2-й. shift[2]представляет направление в нашем штате. Эта часть нацелена на слайд, который должен стать текущим. Он воспроизводит анимацию длительностью slideTime и выбирает одну из подъемных, что означает, что слайд появится с одной из сторон и в конце анимации останется в поле зрения. Если мы находимся в начальном состоянии, воспроизводится анимация void, что означает отсутствие анимации. Он также устанавливает z-index: 1, что гарантирует, что слайд, который должен стать текущим, не перекрывается слайдом, который должен стать предыдущим и невидимым.

3-й. Третье правило css концептуально выполняет ту же задачу, что и второе. Различия заключаются в том, что z-index установлено значение 0, а третий воспроизводит наборанимаций (выдвигается). Он нацелен на слайд, который должен стать предыдущим.

Добавление функций и устранение проблем

Проблема — быстрый клик

Поскольку карусель основана на анимации, у нее есть один недостаток: когда пользователь нажимает быстрее, чем заканчивается анимация, анимация прерывается. Чтобы исправить это, нам нужно создать функцию дросселя, которая будет игнорировать клики пользователя, пока анимация не будет завершена. Затем нам нужно применить эту функцию к нашему обработчику сдвига.

const throttle = (fn: () => void, cooldown: number) => {
  let time = Date.now();
  return () =>
    time + cooldown < Date.now() ? void (fn(), (time = Date.now())) : undefined;
};

// ...

const shiftRight = throttle(() => shiftPosition(-1), slideTime);
const shiftLeft = throttle(() => shiftPosition(1), slideTime);

Функция — точки прогресса

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

const count = Children.count(children)
const progressArray = new Array(count ?? 0).fill(null).map((_, i) => ({ filled: i === shift[0], position: i }))

Функция — делайте точки прогресса кликабельными

Мы можем захотеть, чтобы точки были кликабельными. Нажав на такую ​​точку, мы переместимся к ее аналогу слайда — слайду с тем же номером позиции, что и у точки. Чтобы облегчить это, мы создадим еще одну функцию, которая может установить слайд на определенный. Стоит отметить, что таким образом карусель не может совершать прыжки движения. Он может только заставить сдвиг двигаться. Функцию setPosition также следует регулировать, чтобы избежать скачков анимации.

const setPosition = (position: number) => {
  const diff = position - shift[0]
  const direction = diff / Math.abs(diff)
  const nextShift = [position].concat(shift).slice(0, 2).concat([direction])

  setShift(nextShift)
}

const setPositionHandler = (position: number) =>
  throttle(() => setPosition(position), slideTime);

// ...

<Dot
  filled={filled}
  key={position}
  onClick={setPositionHandler(position)}
/>

Функция — автовоспроизведение

Этого можно добиться, добавив интервал, который периодически автоматически перемещает позицию на следующий слайд. Время интервала не должно быть меньше времени анимации отдельного слайда. Мы добавляем свойство interval и, если оно правильное, setInterval в хуке useEffect.

useEffect(() => {
  if (typeof interval !== "number" || count < 2) return;
  const intervalID = setInterval(
    () => shiftPosition(1),
    Math.max(interval, slideTime)
  );

  return () => clearInterval(intervalID);
}, [shift[0]]);

Применение

Карусель используется для рендеринга массива элементов, которые становятся слайдами.

<StackCarousel>
  {[0, 1, 2, 3].map((n) => (
    <img key={n} src={`/images/${n + 1}.jpg`} />
  ))}
</StackCarousel>

Важно отметить, что у самого компонента Карусель не указана ширина, в результате чего карусель растягивается до ширины своего родителя. Стрелки будут расположены близко к левой и правой стороне родителя соответственно. Таким образом, вы можете легко разместить карусель в контейнере, размер которого соответствует вашим потребностям. Вы можете изменить это поведение, установив width: fit-content для компонента Carousel. Таким образом, стрелки будут внутри карусели, и она будет занимать ровно столько места, сколько необходимо.

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

Если у вас есть изображения или другие элементы с разными размерами, вам нужно обернуть каждый из них другим элементом с одинаковыми размерами для каждого слайда. Например:

const SlideWrapper = styled.div`
  display: flex;
  align-items: center;
  justify-content: center;
  width: // specify your width;
  height: // specify your height;
`;

<StackCarousel>
  {[0, 1, 2, 3, 4].map((n) => (
    <SlideWrapper>
      <img src={`/images/${n + 1}.jpg`} />
    </SlideWrapper>
  ))}
</StackCarousel>

Изменение display: none; на visibility: hidden; в первом правиле css`` без указания width и height растянет карусель до самого большого слайда, но может не подвергаться ленивой загрузке, предоставляемой такими фреймворками, как NextJS.

Профессиональный совет

Вы можете давать имена фрагментам, назначая их переменным. Затем вы можете использовать их, чтобы сделать вашу структуру более читабельной, не загромождая ее дополнительными узлами DOM.

import { Fragment } from 'react';

// ...

const Actions = Fragment;

// ...

<Actions>
  <Action onClick={shiftRight} position="left">
    {"<"}
  </Action>
  <Action onClick={shiftLeft} position="right">
    {">"}
  </Action>
</Actions>

Полный код — минимум

import { Children, Fragment, PropsWithChildren, useState } from "react";
import styled, { css, keyframes } from "styled-components";

const voidAnimation = keyframes``;

const nextRise = keyframes`
  0% { transform: translateX(100%); }
  100% { transform: translateX(0); }
`;

const nextSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(-100%); }
`;

const previousRise = keyframes`
  0% { transform: translateX(-100%); }
  100% { transform: translateX(0); }
`;

const previousSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(100%); }
`;

type SlideShiftDirection = -1 | 0 | 1;

type Props = {
  slideTime?: number;
};

export const StackCarousel: React.FC<
  PropsWithChildren<Props & { className?: string }>
> = ({ className, children, slideTime = 200 }) => {
  const [shift, setShift] = useState([0, 0, 0]); // [current, previous, direction]
  const count = Children.count(children);

  const shiftPosition = (direction: SlideShiftDirection) => {
    const nextShift = Math.abs(count + shift[0] + direction) % count;
    setShift([nextShift].concat(shift).slice(0, 2).concat([direction]));
  };

  const shiftRight = () => shiftPosition(-1);
  const shiftLeft = () => shiftPosition(1);

  return (
    <Carousel className={className}>
      <Slides shift={shift} slideTime={slideTime}>
        {children}
      </Slides>
      {count > 1 && (
        <Actions>
          <Action onClick={shiftRight} position="left">
            {"<"}
          </Action>
          <Action onClick={shiftLeft} position="right">
            {">"}
          </Action>
        </Actions>
      )}
    </Carousel>
  );
};

const Actions = Fragment;

const Slides = styled.section<{ shift: number[]; slideTime: number }>`
  overflow: hidden;
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
  & > * {
    z-index: -1;
    grid-row: 1;
    grid-column: 1;
  }
  ${({ shift, slideTime }) => [
    css`
      & > *:not(:nth-child(${1 + shift[0]})):not(:nth-child(${1 + shift[1]})) {
        display: none;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[0]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextRise
            : previousRise}
          forwards;
        z-index: 1;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[1]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextSet
            : previousSet}
          forwards;
        z-index: 0;
      }
    `,
  ]}
`;

const Carousel = styled.div`
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
`;

const Action = styled.button<{ position: "left" | "right" }>`
  background-color: #eee;
  border-radius: 4px;
  border: none;
  cursor: pointer;
  height: 32px;
  position: absolute;
  top: 50%;
  width: 32px;
  z-index: 5;
  ${(p) => p.position === "left" && "left: 0"};
  ${(p) => p.position === "right" && "right: 0"};
`;

Полный код — полный

import {
  Children,
  Fragment,
  PropsWithChildren,
  useEffect,
  useState,
} from "react";
import styled, { css, keyframes } from "styled-components";

const voidAnimation = keyframes``;

const nextRise = keyframes`
  0% { transform: translateX(100%); }
  100% { transform: translateX(0); }
`;

const nextSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(-100%); }
`;

const previousRise = keyframes`
  0% { transform: translateX(-100%); }
  100% { transform: translateX(0); }
`;

const previousSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(100%); }
`;

type SlideShiftDirection = -1 | 0 | 1;

type Props = {
  slideTime?: number;
  interval?: number;
};

const throttle = (fn: () => void, cooldown: number) => {
  let time = Date.now();
  return () =>
    time + cooldown < Date.now() ? void (fn(), (time = Date.now())) : undefined;
};

export const StackCarousel: React.FC<
  PropsWithChildren<Props & { className?: string }>
> = ({ className, children, interval = 1000, slideTime = 400 }) => {
  const [shift, setShift] = useState([0, 0, 0]); // [current, previous, direction]
  const count = Children.count(children);
  const progressArray = new Array(count ?? 0)
    .fill(null)
    .map((_, i) => ({ filled: i === shift[0], position: i }));

  const shiftPosition = (direction: SlideShiftDirection) => {
    const nextShift = Math.abs(count + shift[0] + direction) % count;
    setShift([nextShift].concat(shift).slice(0, 2).concat([direction]));
  };

  const setPosition = (position: number) => {
    const diff = position - shift[0];
    const direction = diff / Math.abs(diff);
    const nextShift = [position].concat(shift).slice(0, 2).concat([direction]);

    setShift(nextShift);
  };

  const shiftRight = throttle(() => shiftPosition(-1), slideTime);
  const shiftLeft = throttle(() => shiftPosition(1), slideTime);
  const setPositionHandler = (position: number) =>
    throttle(() => setPosition(position), slideTime);

  useEffect(() => {
    if (typeof interval !== "number" || count < 2) return;
    const intervalID = setInterval(
      () => shiftPosition(1),
      Math.max(interval, slideTime)
    );

    return () => clearInterval(intervalID);
  }, [shift[0]]);

  return (
    <Carousel className={className}>
      <Slides shift={shift} slideTime={slideTime}>
        {children}
      </Slides>
      {count > 1 && (
        <Actions>
          <Action onClick={shiftRight} position="left">
            {"<"}
          </Action>
          <Action onClick={shiftLeft} position="right">
            {">"}
          </Action>
        </Actions>
      )}
      {count > 1 && (
        <Dots>
          {progressArray.map(({ filled, position }) => (
            <Dot
              filled={filled}
              key={position}
              onClick={setPositionHandler(position)}
            />
          ))}
        </Dots>
      )}
    </Carousel>
  );
};

const Actions = Fragment;

const Slides = styled.section<{ shift: number[]; slideTime: number }>`
  overflow: hidden;
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
  & > * {
    z-index: -1;
    grid-row: 1;
    grid-column: 1;
  }
  ${({ shift, slideTime }) => [
    css`
      & > *:not(:nth-child(${1 + shift[0]})):not(:nth-child(${1 + shift[1]})) {
        display: none;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[0]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextRise
            : previousRise}
          forwards;
        z-index: 1;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[1]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextSet
            : previousSet}
          forwards;
        z-index: 0;
      }
    `,
  ]}
`;

const Carousel = styled.div`
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
`;

const Action = styled.button<{ position: "left" | "right" }>`
  background-color: #eee;
  border-radius: 4px;
  border: none;
  cursor: pointer;
  height: 32px;
  position: absolute;
  top: 50%;
  width: 32px;
  z-index: 5;
  ${(p) => p.position === "left" && "left: 0"};
  ${(p) => p.position === "right" && "right: 0"};
`;

const Dots = styled.aside`
  bottom: 8px;
  display: flex;
  gap: 8px;
  justify-content: space-between;
  left: 50%;
  position: absolute;
  transform: translateX(-50%);
  z-index: 5;
  cursor: pointer;
`;

const Dot = styled.div<{ filled?: boolean }>`
  background-color: white;
  opacity: ${(p) => (p.filled ? 1 : 0.5)};
  border-radius: 100%;
  height: 8px;
  width: 8px;
  transition: opacity 300ms ease-out;
  &:hover {
    transition: opacity 300ms ease-out;
    opacity: 1;
  }
`;