Поставьте зависимости:
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/arc-list/:
Он импортирует ещё один модуль — astra-rn add кладёт их рядом сам, вручную скопируйте и их:
Проверьте алиас путей. Импорты в коде выше записаны так, как их пишет astra-rn add: @/shared/components, @/shared/utils и @/shared/hooks. Нужен алиас "@/*": ["./src/*"] в tsconfig.json — или поправьте импорты под свои пути.
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Platform, StyleSheet, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaView } from "react-native-safe-area-context";
import { ArcList } from "@/shared/components/molecules/arc-list";
import { LinearGradient } from "expo-linear-gradient";
import { useFonts } from "expo-font";
import { Showcase, useAutoplay, useCustomize } from "~/showcase";
const CUSTOMIZE = [
{ key: "labelColor", label: "Цвет пунктов", type: "color", default: "#c9c9c9" },
{ key: "activeColor", label: "Активный пункт", type: "color", default: "#ffffff" },
{ key: "cardColor", label: "Фон карточки", type: "color", default: "#000000" },
{ key: "fontSize", label: "Размер шрифта", type: "slider", min: 12, max: 28, step: 1, unit: "px", default: 19 },
{ key: "radius", label: "Радиус дуги", type: "slider", min: 120, max: 1200, step: 10, unit: "px", default: 360 },
{ key: "minOpacity", label: "Мин. прозрачность", type: "slider", min: 0, max: 1, step: 0.05, default: 0.5 },
{ key: "minScale", label: "Мин. масштаб", type: "slider", min: 0.4, max: 1, step: 0.02, default: 0.78 },
{ key: "side", label: "Сторона", type: "select", options: [{ value: "left", label: "Слева" }, { value: "right", label: "Справа" }], default: "left" },
{ key: "pause", label: "Пауза автопоказа", type: "slider", min: 0.25, max: 3, step: 0.05, default: 1 },
] as const;
const MOTTOS = [
"Become",
"Wander",
"Breathe",
"Awaken",
"Create",
"Remember",
"Begin Again",
"Stay Wild",
"Keep Wonder",
"Follow The Light",
"Lost & Found",
"Into The Unknown",
"Nothing Lasts",
"Here & Now",
"After The Rain",
"Let It Flow",
"Find Your Way",
"Beyond The Ordinary",
"Until The End",
"While We Can",
];
const CARD_HEIGHT = 340;
const _width = 268;
const CARD_BG = "#000";
const FADE_HEIGHT = 120;
const FADE_STOPS = [1, 0.98, 0.92, 0.8, 0.62, 0.42, 0.24, 0.1, 0.03, 0];
const fade = (alpha: number) => `rgba(5, 5, 5, ${alpha})`;
const TOP_FADE = FADE_STOPS.map(fade) as [string, string, ...string[]];
const BOTTOM_FADE = [...FADE_STOPS].reverse().map(fade) as [
string,
string,
...string[],
];
const FADE_LOCATIONS = FADE_STOPS.map((_, i) => i / (FADE_STOPS.length - 1));
const ITEM_HEIGHT = 44;
const TOUR = [
{ index: 12, hold: 250 },
{ index: 9, hold: 300 },
{ index: 5, hold: 350 },
{ index: 19, hold: 450 },
{ index: 10, hold: 1000 },
{ index: 4, hold: 250 },
{ index: 0, hold: 200 },
{ index: 8, hold: 700 },
];
function glideTo(
scroller: HTMLElement,
top: number,
isCancelled: () => boolean,
) {
const from = scroller.scrollTop;
const duration = Math.min(750, 300 + Math.abs(top - from) * 0.4);
const start = performance.now();
return new Promise<void>((resolve) => {
const step = (now: number) => {
if (isCancelled()) {
resolve();
return;
}
const progress = Math.min(1, (now - start) / duration);
scroller.scrollTop = from + (top - from) * (1 - Math.pow(1 - progress, 4));
if (progress < 1) requestAnimationFrame(step);
else resolve();
};
requestAnimationFrame(step);
});
}
function findScroller(host: HTMLElement | null) {
if (!host) return null;
return (
Array.from(host.querySelectorAll<HTMLElement>("*")).find(
(node) =>
/auto|scroll/.test(getComputedStyle(node).overflowY) &&
node.scrollHeight > node.clientHeight,
) ?? null
);
}
function useWebDragScroll(itemHeight: number) {
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 startY = 0;
let startTop = 0;
let lastY = 0;
let lastTime = 0;
let velocity = 0;
let frame = 0;
let owned: HTMLElement | null = null;
const settle = (target: HTMLElement) => {
owned = null;
const top = Math.round(target.scrollTop / itemHeight) * itemHeight;
Element.prototype.scrollTo.call(target, { top, behavior: "smooth" });
};
const onScrollEnd = (event: Event) => {
if (owned && event.target === owned) event.stopImmediatePropagation();
};
const onDown = (event: MouseEvent) => {
if (event.button !== 0) return;
scroller = findScroller(host);
if (!scroller) return;
event.preventDefault();
cancelAnimationFrame(frame);
owned = scroller;
startY = lastY = event.clientY;
startTop = scroller.scrollTop;
lastTime = performance.now();
velocity = 0;
};
const onMove = (event: MouseEvent) => {
if (!scroller) 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 = () => {
const target = scroller;
scroller = null;
if (!target) return;
let speed = velocity * 16;
const glide = () => {
speed *= 0.92;
if (Math.abs(speed) < 0.5) {
settle(target);
return;
}
target.scrollTop -= speed;
frame = requestAnimationFrame(glide);
};
frame = requestAnimationFrame(glide);
};
host.addEventListener("mousedown", onDown);
window.addEventListener("scrollend", onScrollEnd, true);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
cancelAnimationFrame(frame);
host.removeEventListener("mousedown", onDown);
window.removeEventListener("scrollend", onScrollEnd, true);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [itemHeight]);
return hostRef;
}
export default function ArcListScreen() {
const [fontLoaded] = useFonts({
SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),
});
const hostRef = useWebDragScroll(ITEM_HEIGHT);
const custom = useCustomize(CUSTOMIZE);
const pauseRef = useRef<number>(custom.pause);
pauseRef.current = custom.pause;
const { labelColor, activeColor, fontSize } = custom;
const rows = useMemo(
() =>
MOTTOS.map((life_motto) => (
<ArcList.Item key={life_motto}>
<ArcList.Label
color={labelColor}
activeColor={activeColor}
style={[styles.label, { fontSize }]}
>
{life_motto}
</ArcList.Label>
</ArcList.Item>
)),
[labelColor, activeColor, fontSize],
);
useAutoplay(async ({ wait, isCancelled }) => {
for (const step of TOUR) {
await wait(step.hold * pauseRef.current);
const scroller = findScroller(
hostRef.current as unknown as HTMLElement | null,
);
if (!scroller) continue;
await glideTo(scroller, step.index * ITEM_HEIGHT, isCancelled);
}
});
return (
<Showcase>
<SafeAreaView style={styles.container}>
<View style={styles.stage}>
<View
ref={hostRef}
style={[styles.card, { backgroundColor: custom.cardColor }]}
>
<LinearGradient
pointerEvents="none"
style={[styles.fade, styles.fadeTop]}
colors={TOP_FADE}
locations={FADE_LOCATIONS as any}
/>
<ArcList.Root
height={CARD_HEIGHT}
itemHeight={ITEM_HEIGHT}
minOpacity={custom.minOpacity}
minScale={custom.minScale}
side={custom.side as "left" | "right"}
radius={custom.radius}
defaultIndex={8}
>
<ArcList.Viewport>{rows}</ArcList.Viewport>
</ArcList.Root>
<LinearGradient
pointerEvents="none"
style={[styles.fade, styles.fadeBottom]}
colors={BOTTOM_FADE}
locations={FADE_LOCATIONS as any}
/>
</View>
</View>
</SafeAreaView>
</Showcase>
);
}
const styles = StyleSheet.create({
root: { flex: 1 },
container: { flex: 1, backgroundColor: "#0a0a0a" },
content: { padding: 16, gap: 4 },
title: { color: "#fff", fontSize: 22, fontWeight: "600" },
subtitle: { color: "#6b6b6b", fontSize: 13 },
stage: { flex: 1, alignItems: "center", justifyContent: "center" },
card: {
width: _width,
height: CARD_HEIGHT,
paddingLeft: 18,
borderRadius: 28,
backgroundColor: CARD_BG,
overflow: "hidden",
},
fade: {
position: "absolute",
left: 0,
right: 0,
height: FADE_HEIGHT,
zIndex: 1,
},
fadeTop: { top: 0 },
fadeBottom: { bottom: 0 },
plus: { fontSize: 12, lineHeight: 14, fontWeight: "700" },
label: { fontSize: 19, fontFamily: "SfProRounded" },
caption: {
paddingBottom: 24,
textAlign: "center",
fontSize: 13,
letterSpacing: 2,
textTransform: "uppercase",
color: "#8a8a8a",
},
});childrenТип ReactNode
itemHeight?Тип number
height?Тип number
sweep?Тип number
radius?Тип number
side?Тип ArcSide
snap?Тип boolean
index?Тип number
defaultIndex?Тип number
minOpacity?Тип number
minScale?Тип number
haptics?Тип boolean
onIndexChange?Тип (index: number) => void
style?Тип StyleProp<ViewStyle>