Компоненты/Круговой список

Круговой список

31 января 2025

Круговой список изображений с прокруткой

Установка

npx astra-rn add circular-list
pnpm dlx astra-rn add circular-list
yarn dlx astra-rn add circular-list
bunx --bun astra-rn add circular-list

Файлы

index.tsx
НазадКруговая карусельДалееКруговой лоадер

Вручную

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

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

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

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

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

import { useEffect, useRef } from "react";
import { View, Text, StyleSheet, 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 CircularList from "@/shared/components/molecules/circular-list";
import { Showcase, useAutoplay, useCustomize } from "~/showcase";
import { PHOTO_SOURCES } from "~/assets/photos";

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

const PHOTOS = [
  PHOTO_SOURCES["circular-list-1"],
  PHOTO_SOURCES["circular-list-2"],
  PHOTO_SOURCES["circular-list-3"],
  PHOTO_SOURCES["circular-list-4"],
  PHOTO_SOURCES["circular-list-5"],
  PHOTO_SOURCES["circular-list-6"],
  PHOTO_SOURCES["circular-list-7"],
];

const CUSTOMIZE = [
  { key: "photos", label: "Изображения", type: "images", default: PHOTOS },
  { key: "scaleEnabled", label: "Масштаб центра", type: "toggle", default: false },
  { key: "reverse", label: "Обратная дуга", type: "toggle", default: false },
  { key: "step", label: "Шаг автопоказа", type: "slider", min: 400, max: 3000, step: 50, unit: "ms", default: 1000 },
] 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"),
    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),
  });

  const custom = useCustomize(CUSTOMIZE);
  const CONTACTS = custom.photos;

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

  useAutoplay(async ({ wait }) => {
    goTo(0);
    await wait(900);
    for (const index of [2, 4, 6, 3, 0]) {
      goTo(index);
      await wait(custom.step);
    }
  });

  return (
    <Showcase>
      <StatusBar style="light" />

      <View ref={hostRef} style={styles.listContainer}>
        <CircularList
          data={CONTACTS}
          scaleEnabled={custom.scaleEnabled}
          reverse={custom.reverse}
        />
      </View>
    </Showcase>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0a0a0a",
  },
  header: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "center",
    paddingHorizontal: 20,
    paddingTop: 70,
  },
  backButton: {
    width: 40,
    height: 40,
    borderRadius: 20,
    backgroundColor: "#1a1a1a",
    justifyContent: "center",
    alignItems: "center",
  },
  title: {
    fontSize: 17,
    fontWeight: "600",
    color: "#fff",
  },
  placeholder: {
    width: 40,
  },
  listContainer: {
    height: SCREEN_WIDTH * 0.6,
    marginTop: SCREEN_HEIGHT * 0.35,
  },
  actions: {
    alignItems: "center",
    gap: 24,
    paddingHorizontal: 24,
  },
  actionRow: {
    flexDirection: "row",
    alignItems: "center",
    gap: 20,
  },
  actionButton: {
    width: 56,
    height: 56,
    borderRadius: 28,
    backgroundColor: "#1a1a1a",
    justifyContent: "center",
    alignItems: "center",
  },
  callButton: {
    width: 72,
    height: 72,
    borderRadius: 36,
    backgroundColor: "#30d158",
  },
  hint: {
    fontSize: 13,
    color: "#444",
  },
});

Пропсы

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

Тип string[]

scaleEnabled?

Тип boolean

reverse?

Тип boolean

CircularCarouselItemProps

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

Тип number

imageUri

Тип string

contentOffset

Тип SharedValue<number>

reverse

Тип boolean

scaleEnabled?

Тип boolean