Привет, друзья!
В данном туториале я хочу поделиться с вами опытом решения одной интересной практической задачи.
Предположим, что у нас имеется страница сравнения товаров. На этой странице отображается слайдер с карточками товаров и таблица с их характеристиками. Задача состоит в том, чтобы синхронизировать переключение слайдов и прокрутку таблицы. Условия следующие:
- ширина таблицы должна соответствовать ширине слайдера;
- ширина колонки таблицы должна соответствовать ширине слайда;
- слайды можно переключать с помощью перетаскивания, нажатия на кнопки управления и элементы пагинации;
- таблицу можно прокручивать с помощью колесика мыши (на десктопе) и перемещения указателя (на телефоне);
- при взаимодействии пользователя с одним компонентом второй должен реагировать соответствующим образом: при переключении слайда должна выполняться прокрутка таблицы, при прокрутке таблицы — переключение слайдов.
Если вам это интересно, прошу под кат.
Подготовка и настройка проекта
Для работы с зависимостями я буду использовать 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 нам необходимо знать позиции слайдов. Редактируем файл 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 slideXPositions = useRef<number[]>([]); const onImagesReady = () => { if (!swiperRef.current) return; const slideWidth = swiperRef.current.slides[0].swiperSlideSize; // ! for (const i in items) { slideXPositions.current.push(slideWidth * Number(i)); } setSlideWidth(slideWidth); }; const onSlideChange = () => { if (!swiperRef.current) return; const { transform } = swiperRef.current.wrapperEl.style; const match = transform.match(/-?\d+(\.\d+)?px/); if (!match) return; const scrollLeft = Math.abs(Number(match[0].replace("px", ""))); setScrollLeft(scrollLeft); }; useEffect(() => { if (!swiperRef.current) return; let min = 0; let i = 0; for (const j in slideXPositions.current) { const dif = Math.abs(slideXPositions.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!
ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/701972/

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