Skip to main content

Синхронизация слайдера и таблицы в React-приложении

· 15 min read

Привет, друзья!

В данном туториале я хочу поделиться с вами опытом решения одной интересной практической задачи.

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

  • ширина таблицы должна соответствовать ширине слайдера;
  • ширина колонки таблицы должна соответствовать ширине слайда;
  • слайды можно переключать с помощью перетаскивания, нажатия на кнопки управления и элементы пагинации;
  • таблицу можно прокручивать с помощью колесика мыши (на десктопе) и перемещения указателя (на телефоне);
  • при взаимодействии пользователя с одним компонентом второй должен реагировать соответствующим образом: при переключении слайда должна выполняться прокрутка таблицы, при прокрутке таблицы - переключение слайдов.

Репозиторий с кодом проекта.

Подготовка и настройка проекта

Для работы с зависимостями я буду использовать Yarn. Проект будет реализован на React и TypeScript.

Создаем шаблон проекта с помощью Vite:

# react-slider-table - название проекта
# react-ts - используемый шаблон
yarn create vite react-slider-table --template react-ts

Переходим в созданную директорию, устанавливаем зависимости и запускаем сервер для разработки:

cd react-slider-table
yarn
yarn dev

Для реализации слайдера будет использоваться библиотека Swiper (для синхронизации слайдера и таблицы мы будем использовать некоторые возможности, предоставляемые Swiper, поэтому в рамках туториала рекомендую использовать именно эту библиотеку). Устанавливаем ее:

yarn add swiper
yarn add -D @types/swiper

Импортируем стили слайдера в файле main.tsx:

import "swiper/css";
// для модулей навигации и пагинации
import "swiper/css/navigation";
import "swiper/css/pagination";

Определяем минимальные стили в файле index.css (файл App.css можно удалить):

@import url("https://fonts.googleapis.com/css2?family=Montserrat&display=swap");

* {
font-family: "Montserrat", sans-serif;
}

body {
margin: 0;
}

.app {
margin: 0 auto;
padding: 1rem;
width: 768px;
}

img {
max-width: 100%;
object-fit: cover;
}

.table-wrapper {
overflow: scroll;
scrollbar-width: none;
}

.table-wrapper::-webkit-scrollbar {
display: none;
}

table {
border-collapse: collapse;
overflow: hidden;
}

td {
border: 1px solid gray;
padding: 0.25rem;
text-align: center;
}

.feature-name-row td {
font-weight: bold;
text-align: left;
}

.feature-name {
position: relative;
}

Обратите внимание, что мы фиксируем ширину основного контейнера приложения (.app), поскольку хотим сосредоточится на синхронизации слайдера и таблицы (реализация отзывчивого дизайна потребует некоторых дополнительных вычислений).

Создаем файл types.ts следующего содержания:

export type Feature = {
id: number;
value: string;
};

export type Item = {
id: number;
title: string;
imageUrl: string;
price: number;
features: Feature[];
};

export type Items = Item[];

Создаем файл data.ts следующего содержания:

import { Items } from "./types";

const items: Items = [
{
id: 1,
title: "Title",
imageUrl: `https://picsum.photos/320?random=${Math.random()}`,
price: 100,
features: [
{
id: 1,
value: "Feature",
},
{
id: 2,
value: "Feature2",
},
{
id: 3,
value: "Feature3",
},
{
id: 4,
value: "Feature4",
},
{
id: 5,
value: "Feature5",
},
{
id: 6,
value: "Feature6",
},
],
},
{
id: 2,
title: "Title2",
imageUrl: `https://picsum.photos/320?random=${Math.random()}`,
price: 200,
features: [
{
id: 1,
value: "Feature7",
},
{
id: 2,
value: "Feature8",
},
{
id: 3,
value: "Feature9",
},
{
id: 4,
value: "Feature10",
},
{
id: 5,
value: "Feature11",
},
{
id: 6,
value: "Feature12",
},
],
},
{
id: 3,
title: "Title3",
imageUrl: `https://picsum.photos/320?random=${Math.random()}`,
price: 300,
features: [
{
id: 1,
value: "Feature13",
},
{
id: 2,
value: "Feature14",
},
{
id: 3,
value: "Feature15",
},
{
id: 4,
value: "Feature16",
},
{
id: 5,
value: "Feature17",
},
{
id: 6,
value: "Feature18",
},
],
},
{
id: 4,
title: "Title4",
imageUrl: `https://picsum.photos/320?random=${Math.random()}`,
price: 400,
features: [
{
id: 1,
value: "Feature19",
},
{
id: 2,
value: "Feature20",
},
{
id: 3,
value: "Feature21",
},
{
id: 4,
value: "Feature22",
},
{
id: 5,
value: "Feature23",
},
{
id: 6,
value: "Feature24",
},
],
},
{
id: 5,
title: "Title5",
imageUrl: `https://picsum.photos/320?random=${Math.random()}`,
price: 500,
features: [
{
id: 1,
value: "Feature25",
},
{
id: 2,
value: "Feature26",
},
{
id: 3,
value: "Feature27",
},
{
id: 4,
value: "Feature28",
},
{
id: 5,
value: "Feature29",
},
{
id: 6,
value: "Feature30",
},
],
},
{
id: 6,
title: "Title6",
imageUrl: `https://picsum.photos/320?random=${Math.random()}`,
price: 600,
features: [
{
id: 1,
value: "Feature31",
},
{
id: 2,
value: "Feature32",
},
{
id: 3,
value: "Feature33",
},
{
id: 4,
value: "Feature34",
},
{
id: 5,
value: "Feature35",
},
{
id: 6,
value: "Feature36",
},
],
},
];

export default items;

У нас имеется массив, содержащий 6 объектов с информацией о товарах. Каждый объект товара содержит массив, состоящий из 6 объектов с характеристиками товара.

Создаем директорию components.

Начнем с разработки слайдера. Создаем файл components/Slider.tsx следующего содержания:

// модули
import { Navigation, Pagination } from "swiper";
// компоненты
import { Swiper, SwiperSlide } from "swiper/react";
import { Items } from "../types";

type Props = {
items: Items;
};

// количество отображаемых слайдов
const SLIDES_PER_VIEW = 3;

function Slider({ items }: Props) {
return (
<Swiper
// подключаем модули навигации и пагинации
modules={[Navigation, Pagination]}
// индикатор отображения навигации
navigation={SLIDES_PER_VIEW < items.length}
// индикатор отображения пагинации
pagination={
SLIDES_PER_VIEW < items.length
? {
// элементы пагинации должны быть кликабельными
clickable: true,
}
: undefined
}
// количество отображаемых слайдов
slidesPerView={SLIDES_PER_VIEW}
>
{items.map((item) => (
<SwiperSlide key={item.id}>
<img src={item.imageUrl} alt={item.title} />
<div>
<h2>{item.title}</h2>
<p>{item.price}</p>
</div>
</SwiperSlide>
))}
</Swiper>
);
}

export default Slider;

Импортируем и рендерим слайдер в файле App.tsx:

import Slider from "./components/Slider";
import data from "./data";

function App() {
return (
<div className="app">
<Slider items={data} />
</div>
);
}

export default App;

Результат:


Теперь реализуем компонент таблицы. Создаем файл components/Table.tsx следующего содержания:

import { Items } from "../types";

type Props = {
items: Items;
};

// названия характеристик
const FEATURE_NAMES = [
"Title",
"Title2",
"Title3",
"Title4",
"Title5",
"Title6",
];

function Table({ items }: Props) {
return (
<div className="table-wrapper">
<table>
<tbody>
{items.map((item, i) => (
<React.Fragment key={item.id}>
<tr className="feature-name-row">
<td colSpan={items.length}>
<span className="feature-name">{FEATURE_NAMES[i]}</span>
</td>
</tr>
<tr>
{items.map((_, j) => {
const key = "" + i + j;
return <td key={key}>{items[j].features[i].value}</td>;
})}
</tr>
</React.Fragment>
))}
</tbody>
</table>
</div>
);
}

export default Table;

Обратите внимание на 2 вещи:

  • мы оборачиваем таблицу с overflow: hidden в контейнер с overflow: scroll (.table-wrapper);
  • колонка с названием характеристики растягивается на всю ширину таблицы по количеству товаров (атрибут colspan), а само название оборачивается в элемент span: при прокрутке таблицы название характеристики должно оставаться видимым.

Импортируем и рендерим таблицу в App.tsx:

import Slider from "./components/Slider";
import Table from "./components/Table";
import data from "./data";

function App() {
return (
<div className="app">
<Slider items={data} />
<Table items={data} />
</div>
);
}

export default App;

Результат:


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

Синхронизация ширины слайда и колонки таблицы

Определяем состояние ширины слайда в App.tsx:

const [slideWidth, setSlideWidth] = useState(0);

Данное состояние будет обновляться в слайдере, а использоваться - в таблице:

<Slider
items={data}
// !
setSlideWidth={setSlideWidth}
/>
<Table
items={data}
// !
slideWidth={slideWidth}
/>

Определяем переменную для хранения ссылки на экземпляр Swiper в Slider.tsx:

const swiperRef = useRef<TSwiper>();

Тип TSwiper выглядит так:

// types.ts
import type Swiper from "swiper";

export type TSwiper = Swiper & {
slides: {
swiperSlideSize: number;
}[];
};

Одним из пропов, принимаемых компонентом Swiper, является onSwiper. В качестве аргумента коллбэку этого пропа передается экземпляр Swiper:

<Swiper
onSwiper={(swiper) => {
console.log(swiper);

swiperRef.current = swiper as TSwiper;
}}
// ...
>

Экземпляр Swiper содержит массу полезных свойств:


Интересующее нас значение ширины слайда содержится в свойстве slides[0].swiperSlideSize:


Проп onImageReady компонента Swiper принимает коллбэк для выполнения операций после загрузки всех изображений, используемых в слайдере, что в ряде случаев является критически важным для определения правильной ширины слайда:

<Swiper
onSwiper={(swiper) => {
console.log(swiper);

swiperRef.current = swiper as TSwiper;
}}
onImagesReady={onImagesReady}
// ...
>

Определяем функцию onImagesReady:

const onImagesReady = () => {
if (!swiperRef.current) return;

const slideWidth = swiperRef.current.slides[0].swiperSlideSize;
setSlideWidth(slideWidth);
};

Применяем проп slideWidth в таблице с помощью встроенных стилей (в реальном приложении для этого, скорее всего, будет использоваться одно из решений CSS-in-JS, например, styled-jsx - см. конец статьи):

<tr
// !
style={{
display: "grid",
// 6 колонок с шириной, равной ширине слайда
gridTemplateColumns: `repeat(${items.length}, ${slideWidth}px)`,
}}
>
{items.map((_, j) => {
const key = "" + i + j;
return <td key={key}>{items[j].features[i].value}</td>;
})}
</tr>

Результат:


Синхронизация переключения слайдов и прокрутки таблицы: обработка переключения слайдов

Определяем состояние прокрутки в App.tsx:

const [scrollLeft, setScrollLeft] = useState(0);

Данное состояние, как и состояние ширины слайда, будет обновляться в слайдере, а использоваться - в таблице:

<Slider
items={data}
setSlideWidth={setSlideWidth}
// !
setScrollLeft={setScrollLeft}
/>
<Table
items={data}
slideWidth={slideWidth}
// !
scrollLeft={scrollLeft}
/>

Проп onSlideChange компонента Swiper принимает коллбэк, позволяющий выполнять операции после переключения слайдов (любым способом):

<Swiper
onSlideChange={onSlideChange}
// ...
>

Прежде чем определять функцию onSlideChange, взглянем на то, что происходит с элементом div с классом swiper-wrapper при переключении слайдов:


Видим, что к данному элементу применяется встроенный стиль transform: translate3d(x, y, z), где x - интересующее нас значение прокрутки.

Функция onSlideChange выглядит следующим образом:

const onSlideChange = () => {
if (!swiperRef.current) return;

// извлекаем значение свойства `transform`
const { transform } = swiperRef.current.wrapperEl.style;
// извлекаем значение координаты `x`
const match = transform.match(/-?\d+(\.\d+)?px/);
if (!match) return;

// извлекаем положительное (!) число из значения координаты `x`
// с числами работать удобнее, чем со строками
const scrollLeft = Math.abs(Number(match[0].replace("px", "")));
setScrollLeft(scrollLeft);
};

Для того, чтобы применить проп scrollLeft в таблице, необходимо сделать несколько вещей.

Определяем переменные для хранения ссылок на контейнер для таблицы и саму таблицу, а также переменную для хранения ссылок на элементы с названиями характеристик:

const tableWrapperRef = useRef<HTMLDivElement | null>(null);
const tableRef = useRef<HTMLTableElement | null>(null);
const featureNameRefs = useRef<HTMLSpanElement[]>([]);

Передаем ссылки соответствующим элементам:

<div
className="table-wrapper"
// !
ref={tableWrapperRef}
>
<table
// !
ref={tableRef}
>
{/* ... */}
</table>
</div>

Собираем ссылки на элементы с названиями характеристик после рендеринга компонента:

useEffect(() => {
if (!tableRef.current) return;

featureNameRefs.current = [
...tableRef.current.querySelectorAll(".feature-name"),
] as HTMLSpanElement[];
}, []);

Наконец, выполняем прокрутку таблицы и сдвиг по оси x названий характеристик при изменении значения scrollLeft:

useEffect(() => {
if (!tableWrapperRef.current || !featureNameRefs.current.length) return;

tableWrapperRef.current.scrollLeft = scrollLeft;

featureNameRefs.current.forEach((el) => {
el.style.left = `${scrollLeft}px`;
});
}, [scrollLeft]);

Результат:


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

Синхронизация переключения слайдов и прокрутки таблицы: обработка прокрутки таблицы

Определяем состояние отступа по оси x в App.tsx:

const [offsetX, setOffsetX] = useState(0);

Данное состояние будет обновляться в таблице, а использоваться - в слайдере:

<Slider
items={data}
setSlideWidth={setSlideWidth}
setScrollLeft={setScrollLeft}
// !
offsetX={offsetX}
/>
<Table
items={data}
slideWidth={slideWidth}
scrollLeft={scrollLeft}
// !
setOffsetX={setOffsetX}
/>

Как при прокрутке таблицы с помощью колесика мыши, так и с помощью перемещения указателя, на обертке для таблицы возникает событие scroll:

<div
className="table-wrapper"
// !
onScroll={debouncedOnScroll}
ref={tableWrapperRef}
>

Определяем функцию onScroll:

const onScroll: React.UIEventHandler<HTMLDivElement> = useCallback(() => {
if (!tableRef.current) return;
// извлекаем позицию левого края таблицы по оси `x`
const { x } = tableRef.current.getBoundingClientRect();
// делаем число положительным
setOffsetX(Math.abs(x));
}, []);

Обратите внимание: обработка прокрутки должна выполняться с задержкой, поскольку установка свойства scrollLeft приводит к возникновению события scroll, что может заблокировать переключение слайдов и прокрутку таблицы:

  • offsetX передается в слайдер и используется для переключения слайдов;
  • в обработчике переключения слайдов происходит обновление scrollLeft;
  • scrollLeft используется для выполнения прокрутки таблицы - возникает событие scroll, в обработчике которого обновляется offsetX.

Также обратите внимание, что прокрутка должна выполняться мгновенно: установка стиля scroll-behavior: smooth или выполнение прокрутки с помощью метода scrollTo({ left: scrollLeft, behavior: 'smooth' }) сделает поведение прокрутки непредсказуемым.

Создаем файл hooks/useDebounce.ts следующего содержания:

import { useCallback, useEffect, useRef } from "react";

const useDebounce = (fn: Function, delay: number) => {
const timeoutRef = useRef<number>();

const clearTimer = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = undefined;
}
}, []);

useEffect(() => clearTimer, []);

const cb = useCallback(
(...args: any[]) => {
clearTimer();
timeoutRef.current = setTimeout(() => fn(...args), delay);
},
[fn, delay]
);

return cb;
};

export default useDebounce;

Этот хук возвращает функцию, которая, независимо от количества запусков, вызывается только один раз по прошествии указанного времени:

const ON_SCROLL_DELAY = 250;

const debouncedOnScroll = useDebounce(onScroll, ON_SCROLL_DELAY);

Переходим к самой сложной части туториала.

Применение пропа offsetX в слайдере предполагает знание количества элементов пагинации, определение ближайшего к offsetX элемента и его программное нажатие.

Определяем переменные для хранения ссылок на элементы пагинации и их позиции по оси x:

const paginationBulletRefs = useRef<HTMLSpanElement[]>([]);
const paginationBulletXCoords = useRef<number[]>([]);

Ссылки на элементы пагинации хранятся в свойстве pagination.bullets экземпляра Swiper. Для определения позиций элементов по оси x достаточно умножить индекс элемента на ширину слайда. Расширяем функцию onImagesReady:

const bullets = swiperRef.current.pagination
.bullets as unknown as HTMLSpanElement[];
if (!bullets.length) return;
paginationBulletRefs.current = bullets;

for (const i in bullets) {
paginationBulletXCoords.current.push(slideWidth * Number(i));
}

Определяем эффект для выполнения программного нажатия на соответствующий элемент пагинации при изменении offsetX:

useEffect(() => {
// переменная для минимальной разницы между позицией элемента и отступом
let min = 0;
let i = 0;

for (const j in paginationBulletXCoords.current) {
// вычисляем текущую разницу
const dif = Math.abs(paginationBulletXCoords.current[j] - offsetX);

// текущая разница равна `0`
if (dif === 0) {
min = 0;
i = 0;
break;
}

// текущая разница не равна `0` и минимальная разница равна `0` или текущая разница меньше минимальной разницы
if (dif !== 0 && (min === 0 || dif < min)) {
min = dif;
i = Number(j);
}
}

// выполняем программное нажатие на соответствующий элемент
if (paginationBulletRefs.current[i]) {
paginationBulletRefs.current[i].click();
}
}, [offsetX]);

Обратите внимание: программное нажатие на элемент пагинации приводит к вызову onSlideChange, который обновляет scrollLeft, что приводит к выравниванию таблицы и названий характеристик.

Результат:


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

Обратите внимание: отсутствие задержки вызова onScroll сделает прокрутку более чем на один слайд за раз невозможной, т.е. прокрутка станет последовательной и пошаговой.

Обновление стилей в таблице можно упростить с помощью одного из решений CSS-in-JS, а именно: styled-jsx. Устанавливаем эту библиотеку:

yarn add styled-jsx
yarn add -D @types/styled-jsx

Редактируем файл vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
plugins: [
react({
babel: { plugins: ["styled-jsx/babel"] },
}),
],
});

Редактируем файл vite-env.d.ts:

/// <reference types="vite/client" />
import "react";

declare module "react" {
interface StyleHTMLAttributes {
jsx?: boolean;
global?: boolean;
}
}

Наконец, редактируем файл Table.tsx:

import React, { useCallback, useEffect, useRef } from "react";
import useDebounce from "../hooks/useDebounce";
import { Items } from "../types";

type Props = {
items: Items;
slideWidth: number;
scrollLeft: number;
setOffsetX: React.Dispatch<React.SetStateAction<number>>;
};

const FEATURE_NAMES = [
"Title",
"Title2",
"Title3",
"Title4",
"Title5",
"Title6",
];

const ON_SCROLL_DELAY = 250;

function Table({ items, slideWidth, scrollLeft, setOffsetX }: Props) {
const tableWrapperRef = useRef<HTMLDivElement | null>(null);
const tableRef = useRef<HTMLTableElement | null>(null);

useEffect(() => {
if (!tableWrapperRef.current) return;

tableWrapperRef.current.scrollLeft = scrollLeft;
}, [scrollLeft]);

const onScroll: React.UIEventHandler<HTMLDivElement> = useCallback(() => {
if (!tableRef.current) return;

const { x } = tableRef.current.getBoundingClientRect();
setOffsetX(Math.abs(x));
}, []);

const debouncedOnScroll = useDebounce(onScroll, ON_SCROLL_DELAY);

return (
<>
<div
className="table-wrapper"
onScroll={debouncedOnScroll}
ref={tableWrapperRef}
>
<table ref={tableRef}>
<tbody>
{items.map((item, i) => (
<React.Fragment key={item.id}>
<tr className="feature-name-row">
<td colSpan={items.length}>
<span className="feature-name">{FEATURE_NAMES[i]}</span>
</td>
</tr>
{/* ! */}
<tr className="feature-value-row">
{items.map((_, j) => {
const key = "" + i + j;
return <td key={key}>{items[j].features[i].value}</td>;
})}
</tr>
</React.Fragment>
))}
</tbody>
</table>
</div>
{/* ! */}
<style jsx>{`
.feature-name {
left: ${scrollLeft}px;
}
.feature-value-row {
display: grid;
grid-template-columns: repeat(${items.length}, ${slideWidth}px);
}
`}</style>
</>
);
}

export default Table;

Мы также можем отрефакторить код слайдера, упростив процесс переключения слайдов в ответ на прокрутку таблицы. Экземпляр Swiper предоставляет метод slideTo, позволяющий программно прокручивать слайдер к указанному слайду. Следовательно, вместо позиций элементов пагинации по оси x нам необходимо знать позиции слайдов. Эти позиции содержатся в свойстве slidesGrid. Кроме того, смещение слайдера по оси x можно извлекать из свойства translate. Редактируем файл Slider.tsx:

import { useEffect, useRef } from "react";
import { Navigation, Pagination } from "swiper";
import { Swiper, SwiperSlide } from "swiper/react";
import { Items, TSwiper } from "../types";

type Props = {
items: Items;
setSlideWidth: React.Dispatch<React.SetStateAction<number>>;
setScrollLeft: React.Dispatch<React.SetStateAction<number>>;
offsetX: number;
};

const SLIDES_PER_VIEW = 3;

function Slider({ items, setSlideWidth, setScrollLeft, offsetX }: Props) {
const swiperRef = useRef<TSwiper>();
// !
const slidesGrid = useRef<number[]>([]);

const onImagesReady = () => {
if (!swiperRef.current) return;

const slideWidth = swiperRef.current.slides[0].swiperSlideSize;

// !
slidesGrid.current = swiperRef.current.slidesGrid;

setSlideWidth(slideWidth);
};

const onSlideChange = () => {
if (!swiperRef.current) return;

// !
const scrollLeft = Math.abs(swiperRef.current.translate);
setScrollLeft(scrollLeft);
};

useEffect(() => {
if (!swiperRef.current) return;

let min = 0;
let i = 0;

for (const j in slidesGrid.current) {
const dif = Math.abs(slidesGrid.current[j] - offsetX);

if (dif === 0) {
min = 0;
i = 0;
break;
}

if (dif !== 0 && (min === 0 || dif < min)) {
min = dif;
i = Number(j);
}
}

// !
if (items[i]) {
swiperRef.current.slideTo(i);
}
}, [offsetX]);

return (
<Swiper
onSwiper={(swiper) => {
console.log(swiper);

swiperRef.current = swiper as TSwiper;
}}
modules={[Navigation, Pagination]}
navigation={SLIDES_PER_VIEW < items.length}
onImagesReady={onImagesReady}
onSlideChange={onSlideChange}
pagination={
SLIDES_PER_VIEW < items.length
? {
clickable: true,
}
: undefined
}
slidesPerView={SLIDES_PER_VIEW}
>
{items.map((item) => (
<SwiperSlide key={item.id}>
<img src={item.imageUrl} alt={item.title} />
<div>
<h2>{item.title}</h2>
<p>{item.price}</p>
</div>
</SwiperSlide>
))}
</Swiper>
);
}

export default Slider;

Благодарю за внимание и happy coding!