Обёртка задаёт длинный участок прокрутки и закрепляет внутри него сцену. Пока пользователь листает, сцена стоит на месте, а дети получают прогресс от 0 до 1 как SharedValue: на нём можно строить любые анимации — наклон, масштаб, смену шагов. Когда участок заканчивается, сцена отпускается и список продолжает обычный поток.
Прокрутку даёт хук useStageScroll: подключите его onScroll к Animated.ScrollView и передайте scrollY в сцену. Хуки useStageTransform и useStageStep читают прогресс из любых вложенных компонентов, всё считается на UI-потоке. При «Уменьшении движения» в контекст приходит флаг reduced. Работает на iOS, Android и в вебе.
Поставьте зависимости:
npm install react-native-reanimated react-native-workletspnpm add react-native-reanimated react-native-workletsyarn add react-native-reanimated react-native-workletsbun add react-native-reanimated react-native-workletsСкопируйте эти файлы в src/shared/components/organisms/scroll-stage/:
Проверьте алиас путей. Импорты в коде выше записаны так, как их пишет astra-rn add: @/shared/components, @/shared/utils и @/shared/hooks. Нужен алиас "@/*": ["./src/*"] в tsconfig.json — или поправьте импорты под свои пути.
import React, { useEffect } from "react";
import { StyleSheet, Text, View } from "react-native";
import Animated, {
Extrapolation,
interpolate,
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
type SharedValue,
} from "react-native-reanimated";
import {
ScrollStage,
useStageScroll,
useStageStep,
} from "@/shared/components/organisms/scroll-stage";
import { PHOTO_SOURCES } from "~/assets/photos";
import { Showcase, useCustomize } from "~/showcase";
const STEPS = ["Закреплено", "Прокрутка", "Отпущено"];
const PHOTO = PHOTO_SOURCES["3d-carousel-city"];
const FOREGROUND = "#fafafa";
const BACKGROUND = "#0a0a0a";
const CUSTOMIZE = [
{ key: "stageHeight", label: "Высота сцены", type: "slider", min: 360, max: 800, step: 10, unit: "px", default: 560 },
{ key: "length", label: "Длина прокрутки", type: "slider", min: 1.5, max: 6, step: 0.5, unit: "×", default: 3 },
{ key: "tilt", label: "Начальный наклон", type: "slider", min: 0, max: 60, step: 1, unit: "°", default: 22 },
{ key: "startScale", label: "Начальный масштаб", type: "slider", min: 50, max: 100, step: 1, unit: "%", default: 82 },
{ key: "radius", label: "Скругление карточки", type: "slider", min: 0, max: 40, step: 1, unit: "px", default: 22 },
{ key: "caption", label: "Подпись", type: "text", default: "Закреплено, пока вы листаете", maxLength: 40 },
{ key: "photo", label: "Фото", type: "image", default: PHOTO },
] as const;
type SceneOptions = { tilt: number; startScale: number; radius: number; caption: string; photo: string };
function StepPill({ label, active }: { label: string; active: boolean }) {
const on = useSharedValue(active ? 1 : 0);
useEffect(() => {
on.value = withTiming(active ? 1 : 0, { duration: 200 });
}, [active, on]);
const pill = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(on.value, [0, 1], ["rgba(250,250,250,0)", FOREGROUND]),
borderColor: interpolateColor(on.value, [0, 1], ["rgba(255,255,255,0.14)", FOREGROUND]),
}));
const text = useAnimatedStyle(() => ({
color: interpolateColor(on.value, [0, 1], ["rgba(250,250,250,0.44)", BACKGROUND]),
}));
return (
<Animated.View style={[styles.pill, pill]}>
<Animated.Text style={[styles.pillText, text]}>{label}</Animated.Text>
</Animated.View>
);
}
function StageScene({
progress,
reduced,
options,
}: {
progress: SharedValue<number>;
reduced: boolean;
options: SceneOptions;
}) {
const step = useStageStep(3);
const figure = useAnimatedStyle(() => {
const p = progress.value;
const rotateX = reduced ? 0 : interpolate(p, [0, 0.5], [options.tilt, 0], Extrapolation.CLAMP);
const scale = reduced ? 1 : interpolate(p, [0, 0.5], [options.startScale, 1], Extrapolation.CLAMP);
const y = reduced ? 0 : interpolate(p, [0.5, 1], [0, -40], Extrapolation.CLAMP);
return {
opacity: interpolate(p, [0.85, 1], [1, 0], Extrapolation.CLAMP),
transform: [{ perspective: 900 }, { translateY: y }, { scale }, { rotateX: `${rotateX}deg` }],
};
}, [reduced, options.tilt, options.startScale]);
const image = useAnimatedStyle(() => ({
transform: [
{ scale: reduced ? 1 : interpolate(progress.value, [0, 1], [1.15, 1], Extrapolation.CLAMP) },
],
}), [reduced]);
const bar = useAnimatedStyle(() => ({
transform: [{ scaleX: Math.min(1, Math.max(0, progress.value)) }],
}));
return (
<View style={styles.scene}>
<Animated.View style={[styles.figure, { borderRadius: options.radius }, figure]}>
<View style={styles.media}>
<Animated.Image source={{ uri: options.photo }} resizeMode="cover" style={[styles.photo, image]} />
</View>
<View style={styles.caption}>
<Text style={styles.captionText} numberOfLines={1}>
{options.caption}
</Text>
<View style={styles.track}>
<Animated.View style={[styles.fill, bar]} />
</View>
</View>
</Animated.View>
<View style={styles.steps}>
{STEPS.map((label, index) => (
<StepPill key={label} label={label} active={index === step} />
))}
</View>
</View>
);
}
export default function ScrollStageScreen() {
const custom = useCustomize(CUSTOMIZE);
const { scrollY, onScroll } = useStageScroll();
const stageHeight = custom.stageHeight;
const photo = typeof custom.photo === "string" && custom.photo ? custom.photo : PHOTO;
const options: SceneOptions = {
tilt: custom.tilt,
startScale: custom.startScale / 100,
radius: custom.radius,
caption: custom.caption,
photo,
};
return (
<Showcase>
<View style={styles.page}>
<Animated.ScrollView
style={[styles.scroller, { height: stageHeight }]}
onScroll={onScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
<ScrollStage scrollY={scrollY} height={custom.length * stageHeight} stageHeight={stageHeight}>
{(ctx) => <StageScene progress={ctx.progress} reduced={ctx.reduced} options={options} />}
</ScrollStage>
<View style={[styles.after, { height: stageHeight }]}>
<Text style={styles.afterText}>Сцена отпущена — обычный поток продолжается.</Text>
</View>
</Animated.ScrollView>
</View>
</Showcase>
);
}
const styles = StyleSheet.create({
page: {
flex: 1,
paddingVertical: 48,
alignItems: "center",
justifyContent: "center",
backgroundColor: BACKGROUND,
},
scroller: {
width: "100%",
flexGrow: 0,
},
scene: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 20,
paddingHorizontal: 24,
},
figure: {
width: "100%",
maxWidth: 576,
overflow: "hidden",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.14)",
backgroundColor: BACKGROUND,
transformOrigin: "50% 100%",
},
media: {
width: "100%",
aspectRatio: 16 / 10,
overflow: "hidden",
},
photo: {
width: "100%",
height: "100%",
},
caption: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
gap: 12,
},
captionText: {
flexShrink: 1,
fontSize: 14,
lineHeight: 20,
fontWeight: "500",
letterSpacing: -0.28,
color: FOREGROUND,
},
track: {
width: 64,
height: 4,
borderRadius: 999,
overflow: "hidden",
backgroundColor: "rgba(250,250,250,0.1)",
},
fill: {
width: "100%",
height: "100%",
backgroundColor: FOREGROUND,
transformOrigin: "0% 50%",
},
steps: {
flexDirection: "row",
gap: 8,
},
pill: {
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 10,
paddingVertical: 4,
},
pillText: {
fontSize: 11,
lineHeight: 16,
fontWeight: "500",
},
after: {
alignItems: "center",
justifyContent: "center",
padding: 24,
},
afterText: {
fontSize: 14,
lineHeight: 20,
color: "rgba(250,250,250,0.44)",
},
});scrollYТип SharedValue<number>
height?Тип number
stageHeight?Тип number
viewportHeight?Тип number
top?Тип number
childrenТип ReactNode | ((ctx: StageContext) => ReactNode)
style?Тип StyleProp<ViewStyle>
stageStyle?Тип StyleProp<ViewStyle>