Поставьте зависимости:
npm install expo-haptics react-native-reanimated react-native-workletspnpm add expo-haptics react-native-reanimated react-native-workletsyarn add expo-haptics react-native-reanimated react-native-workletsbun add expo-haptics react-native-reanimated react-native-workletsСкопируйте эти файлы в src/shared/components/molecules/scale-carousel/:
Проверьте алиас путей. Импорты в коде выше записаны так, как их пишет astra-rn add: @/shared/components, @/shared/utils и @/shared/hooks. Нужен алиас "@/*": ["./src/*"] в tsconfig.json — или поправьте импорты под свои пути.
import { useEffect, useMemo, useRef } from "react";
import { View, StyleSheet, Dimensions, Platform } from "react-native";
import { StatusBar } from "expo-status-bar";
import { ScaleCarousel } from "@/shared/components/molecules/scale-carousel";
import { Showcase, useAutoplay, useCustomize } from "~/showcase";
import { PHOTO_SOURCES } from "~/assets/photos";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const ASSETS = [
{
image: {
uri: PHOTO_SOURCES["scale-carousel-1"],
},
},
{
image: {
uri: PHOTO_SOURCES["scale-carousel-2"],
},
},
{
image: {
uri: PHOTO_SOURCES["scale-carousel-3"],
},
},
{
image: {
uri: PHOTO_SOURCES["scale-carousel-4"],
},
},
];
const CUSTOMIZE = [
{ key: "photos", label: "Изображения", type: "images", default: ASSETS.map((asset) => asset.image.uri) },
{ key: "edgeScale", label: "Масштаб по краям", type: "slider", min: 1, max: 3, step: 0.05, default: 1.8 },
{ key: "rotation", label: "Поворот по краям", type: "slider", min: 0, max: 60, step: 1, unit: "°", default: 20 },
{ key: "heightRatio", label: "Высота кадра", type: "slider", min: 30, max: 90, step: 1, unit: "%", default: 60 },
{ key: "background", label: "Фон", type: "color", default: "#0d0d0d" },
] as const;
const TOUR = [
{ hold: 300, index: 1 },
{ hold: 440, index: 2 },
{ hold: 440, index: 3 },
{ hold: 440, index: 2 },
{ hold: 440, index: 1 },
{ hold: 440, index: 0 },
{ hold: 600, index: 0 },
];
function findScroller(host: HTMLElement | null) {
if (!host) return null;
return (
Array.from(host.querySelectorAll<HTMLElement>("*")).find(
(node) =>
/auto|scroll/.test(getComputedStyle(node).overflowX) &&
node.scrollWidth > node.clientWidth,
) ?? null
);
}
function scrollToIndex(host: HTMLElement | null, index: number) {
const scroller = findScroller(host);
if (!scroller) return;
Element.prototype.scrollTo.call(scroller, {
left: index * SCREEN_WIDTH,
behavior: "smooth",
});
}
function useWebDragScroll() {
const hostRef = useRef<View>(null);
useEffect(() => {
if (Platform.OS !== "web") return;
const host = hostRef.current as unknown as HTMLElement | null;
if (!host) return;
let scroller: HTMLElement | null = null;
let startX = 0;
let startLeft = 0;
let lastX = 0;
let lastTime = 0;
let velocity = 0;
let snap = "";
const onDown = (event: MouseEvent) => {
if (event.button !== 0) return;
scroller = findScroller(host);
if (!scroller) return;
event.preventDefault();
snap = scroller.style.scrollSnapType;
scroller.style.scrollSnapType = "none";
startX = lastX = event.clientX;
startLeft = scroller.scrollLeft;
lastTime = performance.now();
velocity = 0;
};
const onMove = (event: MouseEvent) => {
if (!scroller) 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 = () => {
const target = scroller;
scroller = null;
if (!target) return;
const origin = Math.round(startLeft / SCREEN_WIDTH);
const moved = (target.scrollLeft - startLeft) / SCREEN_WIDTH;
const flick = Math.abs(velocity) > 0.3 ? -Math.sign(velocity) : 0;
const step = Math.abs(moved) > 0.3 ? Math.sign(moved) : flick;
const last = Math.round(
(target.scrollWidth - target.clientWidth) / SCREEN_WIDTH,
);
const index = Math.min(last, Math.max(0, origin + step));
Element.prototype.scrollTo.call(target, {
left: index * SCREEN_WIDTH,
behavior: "smooth",
});
target.style.scrollSnapType = snap;
};
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);
};
}, []);
return hostRef;
}
export default function App() {
const custom = useCustomize(CUSTOMIZE);
const hostRef = useWebDragScroll();
const data = useMemo(
() => custom.photos.map((uri) => ({ image: { uri } })),
[custom.photos],
);
useAutoplay(async ({ wait }) => {
for (const step of TOUR) {
await wait(step.hold);
scrollToIndex(hostRef.current as unknown as HTMLElement | null, step.index);
}
});
return (
<Showcase>
<View ref={hostRef} style={[styles.container, { backgroundColor: custom.background }]}>
<StatusBar style="light" />
<ScaleCarousel
data={data}
itemWidth={SCREEN_WIDTH}
itemHeight={SCREEN_HEIGHT * (custom.heightRatio / 100)}
scaleRange={[custom.edgeScale, 1, custom.edgeScale]}
rotationRange={[custom.rotation, 0, -custom.rotation]}
pagingEnabled
renderItem={() => <></>}
/>
</View>
</Showcase>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0d0d0d",
},
});dataТип readonly ItemT[]
renderItemТип (info: { item: ItemT; index: number }) => ReactNode
keyExtractor?Тип (item: ItemT, index: number) => string
itemWidth?Тип number
itemHeight?Тип number
spacing?Тип number
pagingEnabled?Тип boolean
showHorizontalScrollIndicator?Тип boolean
scaleRange?Тип [number, number, number]
rotationRange?Тип [number, number, number]
itemТип ItemT
indexТип number
scaleRange?Тип [number, number, number]
rotationRange?Тип [number, number, number]
scrollXТип SharedValue<number>
renderItemТип (info: { item: ItemT; index: number }) => ReactNode
itemWidthТип number
itemHeightТип number
spacingТип number