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

Параллакс-лента

31 января 2025

Картинки в карусели едут не в такт прокрутке

Установка

npx astra-rn add parallax-carousel
pnpm dlx astra-rn add parallax-carousel
yarn dlx astra-rn add parallax-carousel
bunx --bun astra-rn add parallax-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/parallax-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 { ParallaxCarousel } from "@/shared/components/molecules/parallax-carousel";
import { Showcase, useAutoplay, useCustomize } from "~/showcase";
import { PHOTO_SOURCES } from "~/assets/photos";

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const _width = SCREEN_WIDTH - 50;
const _height = SCREEN_HEIGHT * 0.4;

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

const CUSTOMIZE = [
  { key: "photos", label: "Фото", type: "images", default: ITEMS },
  { key: "intensity", label: "Параллакс", type: "slider", min: 0, max: 2, step: 0.05, default: 1 },
  { key: "height", label: "Высота", type: "slider", min: 0.2, max: 0.7, step: 0.01, default: 0.4 },
  { key: "glide", label: "Перелистывание", type: "slider", min: 150, max: 1500, step: 10, unit: "ms", default: 360 },
  { key: "pause", label: "Пауза кадра", type: "slider", min: 200, max: 3000, step: 20, unit: "ms", default: 500 },
] 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
  );
}

const glides = new WeakMap<HTMLElement, number>();

function stopGlide(scroller: HTMLElement) {
  const frame = glides.get(scroller);
  if (frame !== undefined) cancelAnimationFrame(frame);
  glides.delete(scroller);
}

function glideTo(scroller: HTMLElement, left: number, duration = 520) {
  stopGlide(scroller);
  const from = scroller.scrollLeft;
  const start = performance.now();
  const step = (now: number) => {
    const progress = Math.min(1, (now - start) / duration);
    const eased = 1 - Math.pow(1 - progress, 3);
    scroller.scrollLeft = from + (left - from) * eased;
    if (progress < 1) glides.set(scroller, requestAnimationFrame(step));
    else glides.delete(scroller);
  };
  glides.set(scroller, requestAnimationFrame(step));
}

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();
      stopGlide(scroller);
      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)));
      glideTo(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, duration?: number) => {
    const scroller = findScroller(hostRef.current as unknown as HTMLElement | null);
    if (scroller) glideTo(scroller, index * step, duration);
  };

  const currentIndex = () => {
    const scroller = findScroller(hostRef.current as unknown as HTMLElement | null);
    if (!scroller) return 0;
    return Math.max(0, Math.min(count - 1, Math.round(scroller.scrollLeft / step)));
  };

  return { hostRef, goTo, currentIndex };
}

export default function App() {
  const { hostRef, goTo, currentIndex } = useWebDragCarousel(_width, ITEMS.length);
  const direction = useRef(1);
  const settled = useRef(false);
  const custom = useCustomize(CUSTOMIZE);
  const timing = useRef({ glide: custom.glide, pause: custom.pause });
  timing.current = { glide: custom.glide, pause: custom.pause };
  const { photos } = custom;
  const data = useMemo(() => photos.map((v) => ({ image: { uri: v } })), [photos]);

  useAutoplay(async ({ wait }) => {
    let index = currentIndex();
    if (!settled.current) {
      goTo(index, timing.current.glide);
      await wait(timing.current.pause);
    }
    settled.current = false;
    for (let stepCount = 0; stepCount < (ITEMS.length - 1) * 2; stepCount += 1) {
      if (index + direction.current < 0 || index + direction.current > ITEMS.length - 1) {
        direction.current = -direction.current;
      }
      index += direction.current;
      goTo(index, timing.current.glide);
      await wait(index === 0 ? timing.current.pause + 120 : timing.current.pause);
    }
    settled.current = true;
  });

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

        <ParallaxCarousel
          data={data}
          renderItem={() => <></>}
          itemHeight={custom.height === 0.4 ? _height : SCREEN_HEIGHT * custom.height}
          itemWidth={_width}
          parallaxIntensity={custom.intensity}
        />
      </View>
    </Showcase>
  );
}

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

Пропсы

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

Тип readonly ItemT[]

renderItem

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

keyExtractor?

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

itemWidth?

Тип number

itemHeight?

Тип number

spacing?

Тип number

parallaxIntensity?

Тип number

pagingEnabled?

Тип boolean

showHorizontalScrollIndicator?

Тип boolean

ParallaxCarouselItemProps

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

Тип ItemT

index

Тип number

scrollX

Тип SharedValue<number>

renderItem

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

itemWidth

Тип number

itemHeight

Тип number

spacing

Тип number

parallaxIntensity

Тип number