Поставьте зависимости:
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/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
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