Поставьте зависимости:
npm install expo-blur react-native-reanimatedpnpm add expo-blur react-native-reanimatedyarn add expo-blur react-native-reanimatedbun 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
itemТип ItemT
indexТип number
scrollXТип SharedValue<number>
renderItemТип (info: { item: ItemT; index: number }) => ReactNode
spacing?Тип number
itemWidth?Тип number