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

Карусель-масштаб

31 января 2025

Горизонтальная карусель, в которой карточки у краёв увеличиваются и слегка поворачиваются, а центральная остаётся в обычном масштабе

Установка

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

Файлы

index.tsx
НазадЛинейкаДалееСкретч-карта

Вручную

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

npm install expo-haptics react-native-reanimated react-native-worklets
pnpm add expo-haptics react-native-reanimated react-native-worklets
yarn add expo-haptics react-native-reanimated react-native-worklets
bun add expo-haptics react-native-reanimated react-native-worklets

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

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

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

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

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");

const ASSETS = [
  {
    image: {
      uri: PHOTO_SOURCES["scale-carousel-1"],
    },
  },
  {
    image: {
      uri: PHOTO_SOURCES["scale-carousel-2"],
    },
  },
  {
    image: {
      uri: PHOTO_SOURCES["scale-carousel-3"],
    },
  },
  {
    image: {
      uri: PHOTO_SOURCES["scale-carousel-4"],
    },
  },
];

const CUSTOMIZE = [
  { key: "photos", label: "Изображения", type: "images", default: ASSETS.map((asset) => asset.image.uri) },
  { key: "edgeScale", label: "Масштаб по краям", type: "slider", min: 1, max: 3, step: 0.05, default: 1.8 },
  { key: "rotation", label: "Поворот по краям", type: "slider", min: 0, max: 60, step: 1, unit: "°", default: 20 },
  { key: "heightRatio", label: "Высота кадра", type: "slider", min: 30, max: 90, step: 1, unit: "%", default: 60 },
  { key: "background", label: "Фон", type: "color", default: "#0d0d0d" },
] as const;

const TOUR = [
  { hold: 300, index: 1 },
  { hold: 440, index: 2 },
  { hold: 440, index: 3 },
  { hold: 440, index: 2 },
  { hold: 440, index: 1 },
  { hold: 440, index: 0 },
  { hold: 600, 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 * SCREEN_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 onDown = (event: MouseEvent) => {
      if (event.button !== 0) return;
      scroller = findScroller(host);
      if (!scroller) return;
      event.preventDefault();
      snap = scroller.style.scrollSnapType;
      scroller.style.scrollSnapType = "none";
      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 / SCREEN_WIDTH);
      const moved = (target.scrollLeft - startLeft) / SCREEN_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) / SCREEN_WIDTH,
      );
      const index = Math.min(last, Math.max(0, origin + step));
      Element.prototype.scrollTo.call(target, {
        left: index * SCREEN_WIDTH,
        behavior: "smooth",
      });
      target.style.scrollSnapType = snap;
    };
    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);
    };
  }, []);

  return hostRef;
}

export default function App() {
  const custom = useCustomize(CUSTOMIZE);
  const hostRef = useWebDragScroll();
  const data = useMemo(
    () => custom.photos.map((uri) => ({ image: { uri } })),
    [custom.photos],
  );

  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" />

        <ScaleCarousel
          data={data}
          itemWidth={SCREEN_WIDTH}
          itemHeight={SCREEN_HEIGHT * (custom.heightRatio / 100)}
          scaleRange={[custom.edgeScale, 1, custom.edgeScale]}
          rotationRange={[custom.rotation, 0, -custom.rotation]}
          pagingEnabled
          renderItem={() => <></>}
        />
      </View>
    </Showcase>
  );
}

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

Пропсы

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

Тип readonly ItemT[]

renderItem

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

keyExtractor?

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

itemWidth?

Тип number

itemHeight?

Тип number

spacing?

Тип number

pagingEnabled?

Тип boolean

showHorizontalScrollIndicator?

Тип boolean

scaleRange?

Тип [number, number, number]

rotationRange?

Тип [number, number, number]

ScaleCarouselItemProps

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

Тип ItemT

index

Тип number

scaleRange?

Тип [number, number, number]

rotationRange?

Тип [number, number, number]

scrollX

Тип SharedValue<number>

renderItem

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

itemWidth

Тип number

itemHeight

Тип number

spacing

Тип number