Компоненты/Сцена прокрутки

Сцена прокрутки

28 сентября 2026

Закреплённая сцена, которая передаёт детям прогресс прокрутки от 0 до 1 для анимаций, управляемых скроллом.

Установка

npx astra-rn add scroll-stage
pnpm dlx astra-rn add scroll-stage
yarn dlx astra-rn add scroll-stage
bunx --bun astra-rn add scroll-stage

Файлы

index.tsx
НазадЭтапы на прокруткеДалееЛиния за прокруткой

Обёртка задаёт длинный участок прокрутки и закрепляет внутри него сцену. Пока пользователь листает, сцена стоит на месте, а дети получают прогресс от 0 до 1 как SharedValue: на нём можно строить любые анимации — наклон, масштаб, смену шагов. Когда участок заканчивается, сцена отпускается и список продолжает обычный поток.

Прокрутку даёт хук useStageScroll: подключите его onScroll к Animated.ScrollView и передайте scrollY в сцену. Хуки useStageTransform и useStageStep читают прогресс из любых вложенных компонентов, всё считается на UI-потоке. При «Уменьшении движения» в контекст приходит флаг reduced. Работает на iOS, Android и в вебе.

Вручную

Поставьте зависимости:

npm install react-native-reanimated react-native-worklets
pnpm add react-native-reanimated react-native-worklets
yarn add react-native-reanimated react-native-worklets
bun 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>