Поставьте зависимости:
npm install expo-blur expo-linear-gradientpnpm add expo-blur expo-linear-gradientyarn add expo-blur expo-linear-gradientbun add expo-blur expo-linear-gradientСкопируйте эти файлы в src/shared/components/templates/parallax-header/:
Проверьте алиас путей. Импорты в коде выше записаны так, как их пишет astra-rn add: @/shared/components, @/shared/utils и @/shared/hooks. Нужен алиас "@/*": ["./src/*"] в tsconfig.json — или поправьте импорты под свои пути.
import React, { useEffect, useMemo, useRef } from "react";
import {
Image,
type ImageStyle,
Platform,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { Stack, useRouter, type Router } from "expo-router";
import { SymbolView } from "expo-symbols";
import {
AnimatedScrollView,
AnimatedScrollViewTitle,
AnimatedScrollViewTitleWrapper,
HeaderComponentWrapper,
HeaderNavBar,
} from "@/shared/components/templates/parallax-header";
import { useAutoplay, useCustomize } from "~/showcase";
import { PHOTO_SOURCES } from "~/assets/photos";
const COVER =
PHOTO_SOURCES["parallax-header-1"];
const COVER_FADE_MASK =
"linear-gradient(to bottom, rgba(0,0,0,1), rgba(0,0,0,0.8), rgba(0,0,0,0.05))";
const WEB_COVER_FADE = {
maskImage: COVER_FADE_MASK,
WebkitMaskImage: COVER_FADE_MASK,
} as ImageStyle;
const ROWS = [1, 2, 3, 4, 5];
const ACCENT_HEX = "#EDEDED";
const CUSTOMIZE = [
{ key: "cover", label: "Обложка", type: "image", default: COVER },
{ key: "title", label: "Заголовок", type: "text", default: "Dua Lipa", maxLength: 28 },
{ key: "coverHeight", label: "Высота обложки", type: "slider", min: 240, max: 560, step: 10, unit: "px", default: 400 },
{ key: "topBar", label: "Высота панели", type: "slider", min: 60, max: 160, step: 2, unit: "px", default: 100 },
{ key: "rows", label: "Строк в списке", type: "slider", min: 1, max: 12, step: 1, default: ROWS.length },
{ key: "accent", label: "Цвет кнопки", type: "color", default: ACCENT_HEX },
{ key: "pull", label: "Оттяжка", type: "slider", min: 0, max: 240, step: 5, unit: "px", default: 120 },
{ key: "scrollTo", label: "Глубина прокрутки", type: "slider", min: 100, max: 800, step: 10, unit: "px", default: 300 },
] as const;
const TILES = [1, 2, 3];
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 = Math.max(0, 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.pow(1 - progress, 3);
scroller.scrollTop = from + (to - from) * eased;
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
const pulls = new WeakMap<HTMLElement, { value: number; frame: number }>();
const easeOutCubic = (progress: number) => 1 - Math.pow(1 - progress, 3);
const easeInOutSine = (progress: number) =>
-(Math.cos(Math.PI * progress) - 1) / 2;
function applyPull(scroller: HTMLElement, value: number) {
const content = scroller.firstElementChild as HTMLElement | null;
if (value > 0.5) {
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
get: () => -value,
set: () => {},
});
if (content) content.style.transform = `translateY(${value}px)`;
} else {
delete (scroller as unknown as { scrollTop?: number }).scrollTop;
if (content) content.style.transform = "";
}
scroller.dispatchEvent(new Event("scroll"));
}
function animatePull(
scroller: HTMLElement,
to: number,
duration: number,
ease: (progress: number) => number,
isCancelled: () => boolean = () => false,
) {
const state = pulls.get(scroller) ?? { value: 0, frame: 0 };
pulls.set(scroller, state);
cancelAnimationFrame(state.frame);
const from = state.value;
const start = performance.now();
const step = (now: number) => {
if (isCancelled()) {
animatePull(scroller, 0, 260, easeOutCubic);
return;
}
const progress = Math.min(1, (now - start) / duration);
state.value = from + (to - from) * ease(progress);
applyPull(scroller, state.value);
if (progress < 1) state.frame = requestAnimationFrame(step);
};
state.frame = requestAnimationFrame(step);
}
export const HeaderDemo: React.FC = (): React.ReactNode => {
const router: Router = useRouter() as Router;
const hostRef = useWebDragScroll();
const custom = useCustomize(CUSTOMIZE);
const motion = useRef({ pull: custom.pull, scrollTo: custom.scrollTo });
motion.current = { pull: custom.pull, scrollTo: custom.scrollTo };
const coverHeight = custom.coverHeight;
const coverStyle = useMemo(
() => (coverHeight === 400 ? styles.cover : [styles.cover, { height: coverHeight }]),
[coverHeight],
);
const overlayStyle = useMemo(
() =>
coverHeight === 400
? styles.coverOverlay
: [styles.coverOverlay, { height: coverHeight }],
[coverHeight],
);
const rows = useMemo(
() =>
custom.rows === ROWS.length
? ROWS
: Array.from({ length: custom.rows }, (_, index) => index + 1),
[custom.rows],
);
useAutoplay(async ({ wait, isCancelled }) => {
const scroller = findScroller(
hostRef.current as unknown as HTMLElement | null,
);
if (!scroller) {
await wait(1000);
return;
}
if (scroller.scrollTop > 1) {
glideTo(scroller, 0, 700, isCancelled);
await wait(900);
}
animatePull(scroller, motion.current.pull, 1000, easeInOutSine, isCancelled);
await wait(1050);
animatePull(scroller, 0, 420, easeOutCubic);
await wait(520);
glideTo(scroller, motion.current.scrollTo, 520, isCancelled);
await wait(1500);
glideTo(scroller, 0, 650, isCancelled);
await wait(750);
});
return (
<View ref={hostRef} style={styles.container}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
<Stack.Screen options={{ headerShown: false }} />
<AnimatedScrollView
showsVerticalScrollIndicator={false}
topBarHeight={custom.topBar}
renderTopNavBarComponent={() => (
<HeaderNavBar>
<TouchableOpacity onPress={() => router.back()}>
<SymbolView
name="chevron.backward"
size={18}
tintColor="white"
resizeMode="scaleAspectFit"
/>
</TouchableOpacity>
<Text style={styles.topNavTitle}>{custom.title}</Text>
<TouchableOpacity>
<SymbolView
name="ellipsis"
size={18}
tintColor="white"
resizeMode="scaleAspectFit"
/>
</TouchableOpacity>
</HeaderNavBar>
)}
renderOveralComponent={() => (
<AnimatedScrollViewTitleWrapper>
<></>
</AnimatedScrollViewTitleWrapper>
)}
renderHeaderComponent={() => (
<HeaderComponentWrapper useGradient={false}>
{Platform.OS === "web" ? (
<Image source={{ uri: custom.cover }} style={[coverStyle, WEB_COVER_FADE]} />
) : (
<>
<Image source={{ uri: custom.cover }} style={coverStyle} />
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.95)"]}
style={overlayStyle}
/>
</>
)}
</HeaderComponentWrapper>
)}
>
<View style={styles.content}>
<View style={styles.meta}>
<View style={[styles.bar, styles.barWide]} />
<View style={[styles.bar, styles.barNarrow]} />
</View>
<View style={styles.actions}>
<View
style={[
styles.pill,
styles.pillPrimary,
custom.accent !== ACCENT_HEX && { backgroundColor: custom.accent },
]}
/>
<View style={styles.circle} />
<View style={styles.circle} />
</View>
<View style={styles.list}>
{rows.map((row) => (
<View key={row} style={styles.row}>
<View style={styles.thumb} />
<View style={styles.rowLines}>
<View style={[styles.bar, styles.barWide]} />
<View style={[styles.bar, styles.barNarrow]} />
</View>
<View style={styles.rowEnd} />
</View>
))}
</View>
<View style={styles.tiles}>
{TILES.map((tile) => (
<View key={tile} style={styles.tile} />
))}
</View>
<View style={styles.block} />
</View>
</AnimatedScrollView>
</View>
);
};
const SURFACE = "#131315";
const SURFACE_SOFT = "#232327";
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#000000",
},
topNavTitle: {
color: "white",
fontSize: 17,
fontWeight: "600",
},
title: {
color: "white",
marginLeft: 10,
maxWidth: 2000,
letterSpacing: -1,
},
cover: {
width: "100%",
height: 400,
},
coverOverlay: {
position: "absolute",
top: 0,
width: "100%",
height: 400,
},
content: {
flex: 1,
paddingHorizontal: 20,
paddingTop: 24,
gap: 24,
},
meta: {
gap: 10,
},
bar: {
height: 10,
borderRadius: 5,
backgroundColor: SURFACE_SOFT,
},
barWide: {
width: "58%",
},
barNarrow: {
width: "32%",
opacity: 0.6,
},
actions: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
pill: {
height: 44,
borderRadius: 22,
backgroundColor: SURFACE,
},
pillPrimary: {
flex: 1,
backgroundColor: "#EDEDED",
},
circle: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: SURFACE,
},
list: {
gap: 18,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 14,
},
thumb: {
width: 48,
height: 48,
borderRadius: 10,
backgroundColor: SURFACE,
},
rowLines: {
flex: 1,
gap: 8,
},
rowEnd: {
width: 32,
height: 10,
borderRadius: 5,
backgroundColor: SURFACE,
},
tiles: {
flexDirection: "row",
gap: 12,
},
tile: {
flex: 1,
aspectRatio: 1,
borderRadius: 16,
backgroundColor: SURFACE,
},
block: {
height: 180,
borderRadius: 24,
backgroundColor: SURFACE,
marginBottom: 40,
},
});
export default HeaderDemo;imageHeightТип number
OverlayHeaderContent?Тип React.ReactNode
translateYUpТип Animated.AnimatedInterpolation<string | number> | 0
translateYDownТип Animated.AnimatedInterpolation<string | number> | 0 | any
scaleТип Animated.AnimatedInterpolation<string | number> | 1
imageStyle?Тип StyleProp<ImageStyle>
HeaderComponent?Тип JSX.Element
headerImage?Тип ImageSourcePropType
childrenТип React.ReactNode
size?Тип number
style?Тип StyleProp<TextStyle>
imageHeightТип number
OverlayHeaderContent?Тип React.ReactNode
translateYUpТип Animated.AnimatedInterpolation<string | number> | 0
translateYDownТип Animated.AnimatedInterpolation<string | number> | 0 | any
scaleТип Animated.AnimatedInterpolation<string | number> | 1
imageStyle?Тип StyleProp<ImageStyle>
HeaderComponent?Тип JSX.Element
headerImage?Тип ImageSourcePropType