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

Карусель-поворот

31 января 2025

Горизонтальная карусель, элементы которой поворачиваются в 3D при прокрутке

Установка

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

Файлы

index.tsx
НазадПоворотная ручкаДалееСтопка с поворотом

Вручную

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

npm install @sbaiahmed1/react-native-blur react-native-reanimated
pnpm add @sbaiahmed1/react-native-blur react-native-reanimated
yarn add @sbaiahmed1/react-native-blur react-native-reanimated
bun add @sbaiahmed1/react-native-blur react-native-reanimated

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

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

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

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

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

const ITEM_WIDTH = Dimensions.get("window").width * 0.7;

const CUSTOMIZE = [
  { key: "photos", label: "Изображения", type: "images", default: POSTERS },
  { key: "rotatePercentage", label: "Сила поворота", type: "slider", min: 0, max: 300, step: 5, unit: "%", default: 150 },
  { key: "posterWidth", label: "Ширина постера", type: "slider", min: 150, max: 300, step: 2, unit: "px", default: 250 },
  { key: "posterHeight", label: "Высота постера", type: "slider", min: 200, max: 480, step: 2, unit: "px", default: 390 },
  { key: "radius", label: "Скругление", type: "slider", min: 0, max: 40, step: 1, unit: "px", default: 16 },
  { key: "background", label: "Фон", type: "color", default: "#0a0a0a" },
] as const;

const TOUR = [
  { hold: 400, index: 1 },
  { hold: 460, index: 2 },
  { hold: 460, index: 3 },
  { hold: 450, index: 1 },
  { hold: 450, index: 0 },
  { hold: 700, index: 0 },
];

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

function scrollToIndex(host: HTMLElement | null, index: number) {
  const scroller = findScroller(host);
  if (!scroller) return;
  Element.prototype.scrollTo.call(scroller, {
    left: index * ITEM_WIDTH,
    behavior: "smooth",
  });
}

function useWebDragScroll() {
  const hostRef = useRef<View>(null);

  useEffect(() => {
    if (Platform.OS !== "web") return;
    const host = hostRef.current as unknown as HTMLElement | null;
    if (!host) return;
    let scroller: HTMLElement | null = null;
    let startX = 0;
    let startLeft = 0;
    let lastX = 0;
    let lastTime = 0;
    let velocity = 0;
    let snap = "";
    const holdSnap = (event: Event) => event.stopImmediatePropagation();
    const onDown = (event: MouseEvent) => {
      if (event.button !== 0) return;
      scroller = findScroller(host);
      if (!scroller) return;
      event.preventDefault();
      snap = scroller.style.scrollSnapType;
      scroller.style.scrollSnapType = "none";
      scroller.addEventListener("scrollend", holdSnap, true);
      Element.prototype.scrollTo.call(
        scroller,
        scroller.scrollLeft,
        scroller.scrollTop,
      );
      startX = lastX = event.clientX;
      startLeft = scroller.scrollLeft;
      lastTime = performance.now();
      velocity = 0;
    };
    const onMove = (event: MouseEvent) => {
      if (!scroller) 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 = () => {
      const target = scroller;
      scroller = null;
      if (!target) return;
      const origin = Math.round(startLeft / ITEM_WIDTH);
      const moved = (target.scrollLeft - startLeft) / ITEM_WIDTH;
      const flick = Math.abs(velocity) > 0.3 ? -Math.sign(velocity) : 0;
      const step = Math.abs(moved) > 0.3 ? Math.sign(moved) : flick;
      const last = Math.round(
        (target.scrollWidth - target.clientWidth) / ITEM_WIDTH,
      );
      const index = Math.min(last, Math.max(0, origin + step));
      Element.prototype.scrollTo.call(target, {
        left: index * ITEM_WIDTH,
        behavior: "smooth",
      });
      target.style.scrollSnapType = snap;
      target.removeEventListener("scrollend", holdSnap, true);
    };
    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);
      scroller?.removeEventListener("scrollend", holdSnap, true);
    };
  }, []);

  return hostRef;
}

export default function App() {
  const custom = useCustomize(CUSTOMIZE);
  const hostRef = useWebDragScroll();

  useAutoplay(async ({ wait }) => {
    for (const step of TOUR) {
      await wait(step.hold);
      scrollToIndex(hostRef.current as unknown as HTMLElement | null, step.index);
    }
  });

  return (
    <Showcase>
      <View ref={hostRef} style={[styles.container, { backgroundColor: custom.background }]}>
        <StatusBar style="light" />

        <RotateCarousel
          data={custom.photos.map((uri) => ({ image: { uri } }))}
          renderItem={({ item }) => (
            <Image
              source={item.image}
              style={[
                styles.posterImage,
                {
                  width: custom.posterWidth,
                  height: custom.posterHeight,
                  borderRadius: custom.radius,
                },
              ]}
            />
          )}
          rotatePercentage={custom.rotatePercentage}
        />
      </View>
    </Showcase>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0a0a0a",
    justifyContent: "center",
    alignItems: "center",
  },
  posterImage: {
    width: 250,
    height: 390,
    borderRadius: 16,
  },
});

Пропсы

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

Тип readonly ItemT[]

renderItem

Тип (info: { item: ItemT; index: number }) => React.ReactNode

keyExtractor?

Тип (item: ItemT, index: number) => string

spacing?

Тип number

itemWidth?

Тип number

horizontalSpacing?

Тип number

rotatePercentage?

Тип number

RotateCarouselItemProps

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

Тип ItemT

index

Тип number

scrollX

Тип SharedValue<number>

renderItem

Тип (info: { item: ItemT; index: number }) => ReactNode

spacing?

Тип number

itemWidth?

Тип number

rotatePercentage?

Тип number