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

Размытая карусель

31 января 2025

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

Установка

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

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

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

import { useEffect, 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 { SymbolView } from "expo-symbols";
import { BlurCarousel } from "@/shared/components/molecules/blur-carousel";
import { LinearGradient } from "expo-linear-gradient";
import { Showcase, useAutoplay, useCustomize } from "~/showcase";
import { PHOTO_SOURCES } from "~/assets/photos";

const ITEM_STEP = Dimensions.get("window").width * 0.75;

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

const CUSTOMIZE = [
  { key: "photos", label: "Изображения", type: "images", default: PHOTOS },
  { key: "height", label: "Высота карточки", type: "slider", min: 200, max: 520, step: 2, unit: "px", default: 390 },
  { key: "radius", label: "Скругление", type: "slider", min: 0, max: 48, step: 1, unit: "px", default: 8 },
  { key: "spacing", label: "Отступ", type: "slider", min: 0, max: 60, step: 1, unit: "px", default: 20 },
  { key: "background", label: "Фон", type: "color", default: "#0a0a0a" },
] 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 animateScroll(scroller: HTMLElement, left: number, frame: { id: number }) {
  cancelAnimationFrame(frame.id);
  const from = scroller.scrollLeft;
  const start = performance.now();
  const tick = () => {
    const progress = Math.min(1, (performance.now() - start) / 450);
    scroller.scrollLeft = from + (left - from) * (1 - Math.pow(1 - progress, 3));
    if (progress < 1) frame.id = requestAnimationFrame(tick);
  };
  frame.id = requestAnimationFrame(tick);
}

function useWebDragCarousel(step: number, count: number) {
  const hostRef = useRef<View>(null);
  const frame = useRef({ id: 0 });

  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();
      cancelAnimationFrame(frame.current.id);
      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)));
      animateScroll(scroller, index * step, frame.current);
    };
    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);
      cancelAnimationFrame(frame.current.id);
    };
  }, [step, count]);

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

  return { hostRef, goTo };
}

export default function App() {
  const [fontLoaded] = useFonts({
    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),
    Coolvetica: require("@/assets/fonts/CoolveticaLt-Regular.ttf"),
    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),
  });

  const custom = useCustomize(CUSTOMIZE);

  const DATA = custom.photos.map((image, index) => ({
    id: String(index + 1),
    image,
  }));

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

  useAutoplay(async ({ wait }) => {
    await wait(600);
    for (const [index, hold] of [
      [1, 800],
      [2, 1200],
      [1, 750],
      [0, 450],
    ]) {
      goTo(index);
      await wait(hold);
    }
  });

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

        <BlurCarousel
          data={DATA}
          spacing={custom.spacing}
          renderItem={({ item }) => (
            <View
              style={[
                styles.card,
                { height: custom.height, borderRadius: custom.radius },
              ]}
            >
              <Image
                source={{
                  uri: item.image,
                }}
                style={StyleSheet.absoluteFill}
              />
            </View>
          )}
        />
      </View>
    </Showcase>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0a0a0a",
    alignItems: "center",
    justifyContent: "center",
  },
  header: {
    paddingHorizontal: 20,
    paddingTop: 70,
    paddingBottom: 40,
  },
  title: {
    fontSize: 42,
    fontWeight: "700",
    color: "#fff",
  },
  card: {
    width: "100%",
    height: 390,
    borderRadius: 8,
    overflow: "hidden",
    padding: 24,
    justifyContent: "space-between",
  },
  cardGradient: {
    ...StyleSheet.absoluteFillObject,
  },
  cardTop: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "flex-start",
  },
  cardIcon: {
    width: 60,
    height: 60,
    borderRadius: 20,
    backgroundColor: "rgba(255,255,255,0.2)",
    justifyContent: "center",
    alignItems: "center",
  },
  badge: {
    flexDirection: "row",
    alignItems: "center",
    gap: 4,
    backgroundColor: "rgba(0,0,0,0.2)",
    paddingHorizontal: 10,
    paddingVertical: 6,
    borderRadius: 12,
  },
  badgeText: {
    fontSize: 12,
    fontWeight: "600",
    color: "#fff",
  },
  cardMiddle: {
    gap: 8,
  },
  cardTitle: {
    fontSize: 44,
    fontWeight: "600",
    color: "#fff",
  },
  cardSubtitle: {
    fontSize: 18,
    fontWeight: "600",
    color: "rgba(255,255,255,0.9)",
  },
  cardDescription: {
    fontSize: 14,
    color: "rgba(255,255,255,0.6)",
    lineHeight: 20,
    marginTop: 4,
  },
  cardBottom: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "flex-end",
  },
  statBox: {
    gap: 2,
  },
  statValue: {
    fontSize: 32,
    fontWeight: "700",
    color: "#fff",
  },
  statLabel: {
    fontSize: 13,
    color: "rgba(255,255,255,0.6)",
  },
  arrowButton: {
    width: 48,
    height: 48,
    borderRadius: 24,
    backgroundColor: "rgba(0,0,0,0.2)",
    justifyContent: "center",
    alignItems: "center",
  },
});

Пропсы

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

Тип readonly ItemT[]

renderItem

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

keyExtractor?

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

spacing?

Тип number

itemWidth?

Тип number

horizontalSpacing?

Тип number

BlurCarouselItemProps

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

Тип ItemT

index

Тип number

scrollX

Тип SharedValue<number>

renderItem

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

spacing?

Тип number

itemWidth?

Тип number