Компоненты/Вертикальный поток

Вертикальный поток

31 января 2025

Вертикальная карусель с прилипанием

Установка

npx astra-rn add vertical-flow-carousel
pnpm dlx astra-rn add vertical-flow-carousel
yarn dlx astra-rn add vertical-flow-carousel
bunx --bun astra-rn add vertical-flow-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/vertical-flow-carousel/:

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

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

import { useEffect, useMemo, useRef } from "react";
import {
  View,
  Text,
  StyleSheet,
  Image,
  Dimensions,
  Platform,
} from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StatusBar } from "expo-status-bar";
import { useFonts } from "expo-font";
import sfProRounded from "@/assets/fonts/sf-pro-rounded.ttf";
import VerticalFlowCarousel from "@/shared/components/molecules/vertical-flow-carousel";
import { Showcase, useAutoplay, useCustomize } from "~/showcase";
import { PHOTO_SOURCES } from "~/assets/photos";

const ITEM_HEIGHT = 280;
const ITEM_SPACING = 50;
const STEP = ITEM_HEIGHT + ITEM_SPACING;

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

function smoothScrollTo(scroller: HTMLElement, top: number) {
  HTMLElement.prototype.scrollTo.call(scroller, { top, 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 startY = 0;
    let startTop = 0;
    let lastY = 0;
    let lastTime = 0;
    let velocity = 0;
    let dragging = false;
    const onDown = (event: MouseEvent) => {
      if (event.button !== 0) return;
      event.preventDefault();
      dragging = true;
      startY = lastY = event.clientY;
      startTop = scroller.scrollTop;
      lastTime = performance.now();
      velocity = 0;
    };
    const onMove = (event: MouseEvent) => {
      if (!dragging) return;
      const now = performance.now();
      velocity = (event.clientY - lastY) / Math.max(1, now - lastTime);
      lastY = event.clientY;
      lastTime = now;
      scroller.scrollTop = startTop - (event.clientY - startY);
    };
    const onUp = () => {
      if (!dragging) return;
      dragging = false;
      const projected = scroller.scrollTop - 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 };
}

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

const DATA = [
  {
    id: "1",
    title: "Mountain Peak",
    location: "Switzerland",
    image: PHOTO_SOURCES["vertical-flow-carousel-1"],
  },
  {
    id: "2",
    title: "Ocean Waves",
    location: "Maldives",
    image: PHOTO_SOURCES["vertical-flow-carousel-2"],
  },
  {
    id: "3",
    title: "Forest Trail",
    location: "Canada",
    image: PHOTO_SOURCES["vertical-flow-carousel-3"],
  },
  {
    id: "4",
    title: "Desert Dunes",
    location: "Morocco",
    image: PHOTO_SOURCES["vertical-flow-carousel-4"],
  },
];

const CUSTOMIZE = [
  { key: "photos", label: "Изображения", type: "images", default: DATA.map((item) => item.image) },
  { key: "rotationAngle", label: "Наклон", type: "slider", min: 0, max: 45, step: 1, unit: "°", default: 12 },
  { key: "scaleInactive", label: "Масштаб соседей", type: "slider", min: 0.5, max: 1, step: 0.01, default: 0.8 },
  { key: "opacityInactive", label: "Прозрачность", type: "slider", min: 0, max: 1, step: 0.05, default: 0.5 },
  { key: "showBlur", label: "Размытие", type: "toggle", default: true },
  { key: "blurIntensity", label: "Сила размытия", type: "slider", min: 0, max: 100, step: 1, default: 20 },
  { key: "snapEnabled", label: "Доводка", type: "toggle", default: true },
  { key: "radius", label: "Скругление", type: "slider", min: 0, max: 48, step: 1, unit: "px", default: 20 },
  { key: "showLocation", label: "Подписи", type: "toggle", default: true },
  { key: "textColor", label: "Цвет подписи", type: "color", default: "#ffffff" },
  { key: "interval", label: "Пауза кадра", type: "slider", min: 200, max: 3000, step: 20, unit: "ms", default: 420 },
] as const;

export default function App() {
  const custom = useCustomize(CUSTOMIZE);
  const intervalRef = useRef(custom.interval);
  intervalRef.current = custom.interval;
  const photosKey = custom.photos.join("|");
  const data = useMemo(
    () =>
      DATA.map((item, index) => ({
        ...item,
        image: String(custom.photos[index] ?? item.image),
      })),
    [photosKey],
  );
  const [fontLoaded] = useFonts({
    SfProRounded: sfProRounded,
  });

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

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

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

          <VerticalFlowCarousel
            data={data}
            itemHeight={ITEM_HEIGHT}
            rotationAngle={custom.rotationAngle}
            opacityInactive={custom.opacityInactive}
            scaleInactive={custom.scaleInactive}
            showBlur={custom.showBlur}
            snapEnabled={custom.snapEnabled}
            blurIntensity={custom.blurIntensity}
            contentContainerStyle={styles.carousel}
            renderItem={(item) => (
              <View
                style={[
                  styles.card,
                  custom.radius !== 20 && { borderRadius: custom.radius },
                ]}
              >
                <Image source={{ uri: item.image }} style={styles.image} />
                {custom.showLocation ? (
                  <Text
                    style={[
                      styles.location,
                      fontLoaded && { fontFamily: "SfProRounded" },
                      custom.textColor !== "#ffffff" && {
                        color: custom.textColor,
                      },
                    ]}
                  >
                    {item.location}
                  </Text>
                ) : null}
              </View>
            )}
          />
        </View>
      </GestureHandlerRootView>
    </Showcase>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0a0a0a",
  },
  carousel: {
    paddingTop: Math.max(80, (height - ITEM_HEIGHT) / 2 - 40),
    paddingHorizontal: 20,
    paddingBottom: 40,
  },
  card: {
    width: width - 40,
    height: ITEM_HEIGHT,
    borderRadius: 20,
    overflow: "hidden",
  },
  image: {
    width: "100%",
    height: "100%",
  },
  location: {
    fontSize: 14,
    color: "rgba(255,255,255,0.7)",
  },
});

Пропсы

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

Тип T[]

renderItem

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

itemHeight?

Тип number

spacing?

Тип number

containerStyle?

Тип ViewStyle

contentContainerStyle?

Тип ViewStyle

onItemPress?

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

showBlur?

Тип boolean

blurIntensity?

Тип number

rotationAngle?

Тип number

scaleInactive?

Тип number

opacityInactive?

Тип number

snapEnabled?

Тип boolean

AnimatedItemProps

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

Тип T

index

Тип number

scrollY

Тип SharedValue<number>

itemHeight

Тип number

spacing

Тип number

rotationAngle

Тип number

scaleInactive

Тип number

opacityInactive

Тип number

showBlur

Тип boolean

blurIntensity

Тип number

children

Тип React.ReactNode

totalItems

Тип number