Компоненты/Material-карусель

Material-карусель

31 января 2025

Горизонтальная карусель в духе Material

Установка

npx astra-rn add material-carousel
pnpm dlx astra-rn add material-carousel
yarn dlx astra-rn add material-carousel
bunx --bun astra-rn add material-carousel

Файлы

index.tsx
НазадОбщая геометрияДалееМега-меню

Вручную

Поставьте зависимости:

npm install react-native-reanimated
pnpm add react-native-reanimated
yarn add react-native-reanimated
bun add react-native-reanimated

Скопируйте эти файлы в src/shared/components/molecules/material-carousel/:

Проверьте алиас путей. Импорты в коде выше записаны так, как их пишет astra-rn add: @/shared/components, @/shared/utils и @/shared/hooks. Нужен алиас "@/*": ["./src/*"] в tsconfig.json — или поправьте импорты под свои пути.

Использование

import { useEffect, useRef } from "react";
import { View, StyleSheet, Platform } from "react-native";
import { StatusBar } from "expo-status-bar";
import MaterialCarousel from "@/shared/components/molecules/material-carousel";
import { SMALL_IMAGE } from "@/shared/components/molecules/material-carousel/const";
import { Showcase, useAutoplay, useCustomize } from "~/showcase";
import { PHOTO_SOURCES } from "~/assets/photos";

const ITEMS: string[] = [
  PHOTO_SOURCES["material-carousel-1"],
  PHOTO_SOURCES["material-carousel-2"],
  PHOTO_SOURCES["material-carousel-3"],
  PHOTO_SOURCES["material-carousel-4"],
];

const CUSTOMIZE = [
  { key: "photos", label: "Изображения", type: "images", default: ITEMS },
  { key: "imageHeight", label: "Высота", type: "slider", min: 200, max: 560, step: 10, unit: "px", default: 400 },
  { key: "radius", label: "Скругление", type: "slider", min: 0, max: 60, step: 1, unit: "px", default: 24 },
  { key: "scaleEnabled", label: "Масштаб краёв", type: "toggle", default: false },
  { key: "glideSpeed", label: "Скорость показа", type: "slider", min: 0.25, max: 3, step: 0.05, default: 1 },
] as const;

function findScroller(host: HTMLElement | null) {
  if (!host) return null;
  return (
    [host, ...Array.from(host.querySelectorAll<HTMLElement>("*"))].find(
      (node) => /auto|scroll/.test(getComputedStyle(node).overflowX),
    ) ?? null
  );
}

function nativeScrollTo(node: HTMLElement, options: ScrollToOptions) {
  Reflect.apply(Element.prototype.scrollTo, node, [options]);
}

function useWebDragCarousel(step: number) {
  const hostRef = useRef<View>(null);

  useEffect(() => {
    if (Platform.OS !== "web") return;
    const host = hostRef.current as unknown as HTMLElement | null;
    const scroller = findScroller(host);
    if (!host || !scroller) return;
    let startX = 0;
    let startLeft = 0;
    let lastX = 0;
    let lastTime = 0;
    let velocity = 0;
    let dragging = false;
    const onDown = (event: MouseEvent) => {
      if (event.button !== 0) return;
      event.preventDefault();
      dragging = true;
      startX = lastX = event.clientX;
      startLeft = scroller.scrollLeft;
      lastTime = performance.now();
      velocity = 0;
    };
    const onMove = (event: MouseEvent) => {
      if (!dragging) return;
      const now = performance.now();
      velocity = (event.clientX - lastX) / Math.max(1, now - lastTime);
      lastX = event.clientX;
      lastTime = now;
      scroller.scrollLeft = startLeft - (event.clientX - startX);
    };
    const onUp = () => {
      if (!dragging) return;
      dragging = false;
      const maxLeft = scroller.scrollWidth - scroller.clientWidth;
      const projected = scroller.scrollLeft - velocity * 180;
      const lastSnap = Math.floor(maxLeft / step) * step;
      const left = Math.max(0, Math.min(lastSnap, Math.round(projected / step) * step));
      nativeScrollTo(scroller, { left, behavior: "smooth" });
    };
    host.addEventListener("mousedown", onDown);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    return () => {
      host.removeEventListener("mousedown", onDown);
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
    };
  }, [step]);

  const glide = (index: number, duration: number, isCancelled: () => boolean) =>
    new Promise<void>((resolve) => {
      const scroller = findScroller(hostRef.current as unknown as HTMLElement | null);
      if (!scroller) {
        resolve();
        return;
      }
      const from = scroller.scrollLeft;
      const maxLeft = scroller.scrollWidth - scroller.clientWidth;
      const to = Math.max(0, Math.min(maxLeft, index * step));
      if (Math.abs(to - from) < 1) {
        resolve();
        return;
      }
      const started = performance.now();
      const frame = (now: number) => {
        if (isCancelled()) {
          resolve();
          return;
        }
        const progress = Math.min(1, (now - started) / duration);
        const eased =
          progress < 0.5
            ? 4 * progress * progress * progress
            : 1 - Math.pow(-2 * progress + 2, 3) / 2;
        scroller.scrollLeft = from + (to - from) * eased;
        if (progress < 1) requestAnimationFrame(frame);
        else resolve();
      };
      requestAnimationFrame(frame);
    });

  return { hostRef, glide };
}

export default function App() {
  const { hostRef, glide } = useWebDragCarousel(SMALL_IMAGE);
  const custom = useCustomize(CUSTOMIZE);
  const glideSpeed = useRef<number>(custom.glideSpeed);
  glideSpeed.current = custom.glideSpeed;

  useAutoplay(async ({ wait, isCancelled }) => {
    await glide(0, 500 / glideSpeed.current, isCancelled);
    await wait(250);
    await glide(ITEMS.length - 1, 750 / glideSpeed.current, isCancelled);
    await wait(350);
    await glide(0, 1150 / glideSpeed.current, isCancelled);
    await wait(450);
  });

  return (
    <Showcase>
      <View style={styles.container}>
        <StatusBar style="inverted" />

        <View ref={hostRef}>
          <MaterialCarousel
            data={custom.photos}
            renderItem={() => <></>}
            scaleEnabled={custom.scaleEnabled}
            imageHeight={custom.imageHeight}
            borderRadius={custom.radius}
          />
        </View>
      </View>
    </Showcase>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0a0a0a",
    justifyContent: "center",
    alignItems: "center",
  },
});

Пропсы

Пропс
Описание
data

Тип string[]

renderItem

Тип (item: string, index: number) => React.ReactNode

scaleEnabled?

Тип boolean

imageHeight?

Тип number

borderRadius?

Тип number

ICarouselItem

Пропс
Описание
item

Тип string

scrollX

Тип SharedValue<number>

index

Тип number

scaleEnabled

Тип boolean

renderItem?

Тип (item: string, index: number) => React.ReactNode

dataLength?

Тип number

imageHeight?

Тип number

borderRadius?

Тип number