Поставьте зависимости:
npm install @react-native-masked-view/masked-view expo-blur expo-linear-gradient react-native-easing-gradient react-native-reanimated react-native-safe-area-contextpnpm add @react-native-masked-view/masked-view expo-blur expo-linear-gradient react-native-easing-gradient react-native-reanimated react-native-safe-area-contextyarn add @react-native-masked-view/masked-view expo-blur expo-linear-gradient react-native-easing-gradient react-native-reanimated react-native-safe-area-contextbun add @react-native-masked-view/masked-view expo-blur expo-linear-gradient react-native-easing-gradient react-native-reanimated react-native-safe-area-contextСкопируйте эти файлы в src/shared/components/organisms/animated-header-scrollview/:
Проверьте алиас путей. Импорты в коде выше записаны так, как их пишет astra-rn add: @/shared/components, @/shared/utils и @/shared/hooks. Нужен алиас "@/*": ["./src/*"] в tsconfig.json — или поправьте импорты под свои пути.
import { StatusBar } from "expo-status-bar";
import { useEffect, useMemo, useRef } from "react";
import { Platform, StyleSheet, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { AnimatedHeaderScrollView } from "@/shared/components/organisms/animated-header-scrollview";
import { useAutoplay, useCustomize } from "~/showcase";
const CUSTOMIZE = [
{ key: "title", label: "Заголовок", type: "text", default: "Overview", maxLength: 24 },
{ key: "titleSize", label: "Размер заголовка", type: "slider", min: 24, max: 56, step: 1, unit: "px", default: 40 },
{ key: "background", label: "Фон", type: "color", default: "#000000" },
{ key: "surface", label: "Карточки", type: "color", default: "#131315" },
{ key: "surfaceSoft", label: "Заглушки", type: "color", default: "#1D1D20" },
{ key: "radius", label: "Скругление", type: "slider", min: 4, max: 40, step: 1, unit: "px", default: 24 },
{ key: "heroHeight", label: "Высота обложки", type: "slider", min: 120, max: 480, step: 10, unit: "px", default: 300 },
{ key: "glide", label: "Скорость прокрутки", type: "slider", min: 300, max: 2500, step: 50, unit: "ms", default: 900 },
] as const;
const ROWS = [1, 2, 3, 4];
const TILES = [1, 2, 3, 4];
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() {
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;
const onDown = (event: MouseEvent) => {
if (event.button !== 0) return;
scroller = findScroller(host);
if (!scroller) return;
event.preventDefault();
cancelAnimationFrame(frame);
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.95;
if (Math.abs(speed) < 0.5) return;
target.scrollTop -= speed;
frame = requestAnimationFrame(glide);
};
frame = requestAnimationFrame(glide);
};
host.addEventListener("mousedown", onDown);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
cancelAnimationFrame(frame);
host.removeEventListener("mousedown", onDown);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, []);
return hostRef;
}
function glideTo(
scroller: HTMLElement,
top: number,
duration: number,
isCancelled: () => boolean,
) {
const from = scroller.scrollTop;
const to = Math.min(top, scroller.scrollHeight - scroller.clientHeight);
const start = performance.now();
const step = (now: number) => {
if (isCancelled()) return;
const progress = Math.min(1, (now - start) / duration);
const eased = (1 - Math.cos(Math.PI * progress)) / 2;
scroller.scrollTop = from + (to - from) * eased;
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
export default function App() {
const hostRef = useWebDragScroll();
const custom = useCustomize(CUSTOMIZE);
const glideRef = useRef<number>(custom.glide);
glideRef.current = custom.glide;
const surface = {
backgroundColor: custom.surface,
borderRadius: custom.radius,
};
const tileSurface = {
backgroundColor: custom.surface,
borderRadius: Math.max(0, custom.radius - 4),
};
const soft = { backgroundColor: custom.surfaceSoft };
const largeTitleStyle = useMemo(
() => StyleSheet.flatten([styles.largeTitle, { fontSize: custom.titleSize }]),
[custom.titleSize],
);
useAutoplay(async ({ wait, isCancelled }) => {
const scroller = findScroller(
hostRef.current as unknown as HTMLElement | null,
);
if (!scroller) {
await wait(1000);
return;
}
const glide = glideRef.current;
const extra = glide - 900;
if (scroller.scrollTop > 0) {
glideTo(scroller, 0, glide, isCancelled);
await wait(1000 + extra);
}
await wait(900);
glideTo(scroller, 320, glide, isCancelled);
await wait(1200 + extra);
glideTo(scroller, Number.MAX_SAFE_INTEGER, glide + 100, isCancelled);
await wait(1900 + extra);
glideTo(scroller, 360, glide, isCancelled);
await wait(1100 + extra);
glideTo(scroller, 0, glide, isCancelled);
await wait(1400 + extra);
});
return (
<SafeAreaProvider>
<GestureHandlerRootView
style={[styles.root, { backgroundColor: custom.background }]}
>
<StatusBar style="light" />
<View ref={hostRef} style={styles.fill}>
<AnimatedHeaderScrollView
largeTitle={custom.title}
largeHeaderTitleStyle={largeTitleStyle}
largeHeaderSubtitleStyle={styles.largeSubtitle}
smallHeaderTitleStyle={styles.smallTitle}
>
<View style={[styles.hero, surface, { height: custom.heroHeight }]} />
<View style={styles.row}>
<View style={[styles.tile, styles.fill, tileSurface]} />
<View style={[styles.tile, styles.fill, tileSurface]} />
</View>
<View style={[styles.section, surface]}>
{ROWS.map((row) => (
<View key={row} style={styles.listRow}>
<View style={[styles.dot, soft]} />
<View style={styles.fill}>
<View style={[styles.bar, styles.barWide, soft]} />
<View style={[styles.bar, styles.barNarrow, soft]} />
</View>
</View>
))}
</View>
<View style={styles.grid}>
{TILES.map((tile) => (
<View key={tile} style={[styles.gridTile, tileSurface]} />
))}
</View>
<View style={[styles.block, surface]} />
</AnimatedHeaderScrollView>
</View>
</GestureHandlerRootView>
</SafeAreaProvider>
);
}
const SURFACE = "#131315";
const SURFACE_SOFT = "#1D1D20";
const GAP = 12;
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#000",
},
fill: {
flex: 1,
},
largeTitle: {
fontSize: 40,
fontWeight: "700",
letterSpacing: -0.8,
},
largeSubtitle: {
fontSize: 14,
},
smallTitle: {
fontSize: 17,
fontWeight: "600",
},
hero: {
height: 300,
borderRadius: 24,
backgroundColor: SURFACE,
marginBottom: GAP,
},
row: {
flexDirection: "row",
gap: GAP,
marginBottom: GAP,
},
tile: {
height: 110,
borderRadius: 20,
backgroundColor: SURFACE,
},
section: {
borderRadius: 24,
backgroundColor: SURFACE,
paddingVertical: 6,
marginBottom: GAP,
},
listRow: {
flexDirection: "row",
alignItems: "center",
gap: 14,
paddingVertical: 14,
paddingHorizontal: 16,
},
dot: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: SURFACE_SOFT,
},
bar: {
height: 10,
borderRadius: 5,
backgroundColor: SURFACE_SOFT,
},
barWide: {
width: "62%",
},
barNarrow: {
width: "34%",
marginTop: 8,
opacity: 0.6,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: GAP,
marginBottom: GAP,
},
gridTile: {
width: "48%",
aspectRatio: 1,
borderRadius: 20,
backgroundColor: SURFACE,
},
block: {
height: 260,
borderRadius: 24,
backgroundColor: SURFACE,
},
});largeTitleТип string
subtitle?Тип string
childrenТип React.ReactNode
rightComponent?Тип React.ReactNode
showsVerticalScrollIndicator?Тип boolean
contentContainerStyle?Тип StyleProp<ViewStyle>
headerBackgroundGradient?Тип GradientConfig
headerBlurConfig?Тип BlurConfig
smallTitleBlurIntensity?Тип number
smallTitleBlurTint?Тип BlurTint
maskGradientColors?Тип MaskGradientColors
largeTitleBlurIntensity?Тип number
largeHeaderTitleStyle?Тип StyleProp<TextStyle>
largeHeaderSubtitleStyle?Тип StyleProp<TextStyle>
smallHeaderTitleStyle?Тип StyleProp<TextStyle>
smallHeaderSubtitleStyle?Тип StyleProp<TextStyle>
colorsТип readonly [ColorValue, ColorValue, ...ColorValue[]]
locations?Тип readonly [number, number, ...number[]] | null
start?Тип LinearGradientPoint
end?Тип LinearGradientPoint