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

Карусель-наклон

31 января 2025

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

Установка

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

Файлы

index.tsx
НазадСмена темыДалееЛента дней с липкой датой

Вручную

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

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

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

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

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

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

const ITEM_WIDTH = 350;
const ITEM_MARGIN = 20;
const STEP = ITEM_WIDTH + ITEM_MARGIN * 2;

const PHOTOS = [
  PHOTO_SOURCES["tilt-carousel-1"],
  PHOTO_SOURCES["tilt-carousel-2"],
  PHOTO_SOURCES["tilt-carousel-3"],
  PHOTO_SOURCES["tilt-carousel-4"],
];

const CUSTOMIZE = [
  { key: "photos", label: "Изображения", type: "images", default: PHOTOS },
  { key: "rotationAngle", label: "Угол наклона", type: "slider", min: 0, max: 80, step: 1, unit: "°", default: 30 },
  { key: "translateY", label: "Сдвиг вниз", type: "slider", min: 0, max: 200, step: 1, unit: "px", default: 55 },
  { key: "itemHeight", label: "Высота карточки", type: "slider", min: 250, max: 650, step: 5, unit: "px", default: 500 },
  {
    key: "origin",
    label: "Ось наклона",
    type: "select",
    options: [
      { value: "top", label: "Верх" },
      { value: "center", label: "Центр" },
      { value: "bottom", label: "Низ" },
    ],
    default: "bottom",
  },
  { key: "blur", label: "Размытие", type: "toggle", default: true },
  { key: "radius", label: "Скругление", type: "slider", min: 0, max: 60, step: 1, unit: "px", default: 20 },
  { key: "borderColor", label: "Цвет рамки", type: "color", default: "#1a1a1a" },
  { key: "pace", 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 smoothScrollTo(scroller: HTMLElement, left: number) {
  HTMLElement.prototype.scrollTo.call(scroller, { left, behavior: "smooth" });
}

function useWebDragCarousel(step: number, count: 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 projected = scroller.scrollLeft - velocity * 180;
      const index = Math.max(
        0,
        Math.min(count - 1, Math.round(projected / step)),
      );
      smoothScrollTo(scroller, index * step);
    };
    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, count]);

  const goTo = (index: number) => {
    const scroller = findScroller(
      hostRef.current as unknown as HTMLElement | null,
    );
    if (scroller) smoothScrollTo(scroller, index * step);
  };

  return { hostRef, goTo };
}

interface Assets {
  id: string;
  image: string;
}

export default function App() {
  const custom = useCustomize(CUSTOMIZE);
  const assets: Assets[] = custom.photos.map((image, index) => ({
    id: String(index + 1),
    image,
  }));
  const paceRef = useRef(custom.pace);
  paceRef.current = custom.pace;

  const { hostRef, goTo } = useWebDragCarousel(STEP, assets.length);

  useAutoplay(async ({ wait }) => {
    goTo(0);
    await wait(700 / paceRef.current);
    for (const index of [1, 2, 3, 1, 0]) {
      goTo(index);
      await wait(520 / paceRef.current);
    }
    await wait(200 / paceRef.current);
  });

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

        <View ref={hostRef} style={styles.carouselContainer}>
          <TiltCarousel
            data={assets}
            itemWidth={ITEM_WIDTH}
            itemHeight={custom.itemHeight}
            marginHorizontal={ITEM_MARGIN}
            rotationAngle={custom.rotationAngle}
            translateYValue={custom.translateY}
            transformOrigin={custom.origin as "top" | "center" | "bottom"}
            useBlur={custom.blur}
            renderItem={({ item }) => (
              <View
                style={[
                  styles.card,
                  { borderRadius: custom.radius, borderColor: custom.borderColor },
                ]}
              >
                <Image
                  source={{ uri: item.image }}
                  style={styles.image}
                  resizeMode="cover"
                />
              </View>
            )}
            keyExtractor={(item) => item.id}
          />
        </View>
      </View>
    </Showcase>
  );
}

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

  carouselContainer: {
    marginTop: 0,
  },
  card: {
    flex: 1,
    backgroundColor: "#000",
    borderRadius: 20,
    overflow: "hidden",
    borderWidth: 1,
    borderColor: "#1a1a1a",
  },
  image: {
    width: "100%",
    height: "100%",
  },
});

Пропсы

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

Тип readonly T[]

renderItem

Тип (info: ICarouselRenderItem<T>) => React.ReactNode

keyExtractor?

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

itemWidth?

Тип number

itemHeight?

Тип number

marginHorizontal?

Тип number

rotationAngle?

Тип number

translateYValue?

Тип number

transformOrigin?

Тип "bottom" | "center" | "top"

useBlur?

Тип boolean

scrollEventThrottle?

Тип number

decelerationRate?

Тип "fast" | "normal" | number

showsHorizontalScrollIndicator?

Тип boolean

ICarouselItem

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

Тип T

index

Тип number

scrollX

Тип SharedValue<number>

renderItem

Тип (info: ICarouselRenderItem<T>) => React.ReactNode

itemWidth

Тип number

itemHeight

Тип number

marginHorizontal

Тип number

fullWidth

Тип number

rotationAngle

Тип number

translateYValue

Тип number

transformOrigin

Тип "bottom" | "center" | "top"

useBlur

Тип boolean

ICarouselRenderItem

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

Тип T

index

Тип number

scrollX

Тип SharedValue<number>

itemWidth

Тип number

itemHeight

Тип number

marginHorizontal

Тип number

fullWidth

Тип number