Компоненты/Растущая шапка

Растущая шапка

31 января 2025

Шапка в стиле iOS: крупная сверху, сжимается при прокрутке

Установка

npx astra-rn add animated-header-scrollview
pnpm dlx astra-rn add animated-header-scrollview
yarn dlx astra-rn add animated-header-scrollview
bunx --bun astra-rn add animated-header-scrollview

Файлы

index.tsx
НазадПружинный календарьДалееЖивое поле ввода

Вручную

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

npm install @react-native-masked-view/masked-view expo-blur expo-linear-gradient react-native-easing-gradient react-native-reanimated react-native-safe-area-context
pnpm add @react-native-masked-view/masked-view expo-blur expo-linear-gradient react-native-easing-gradient react-native-reanimated react-native-safe-area-context
yarn add @react-native-masked-view/masked-view expo-blur expo-linear-gradient react-native-easing-gradient react-native-reanimated react-native-safe-area-context
bun 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>

GradientConfig

Пропс
Описание
colors

Тип readonly [ColorValue, ColorValue, ...ColorValue[]]

locations?

Тип readonly [number, number, ...number[]] | null

start?

Тип LinearGradientPoint

end?

Тип LinearGradientPoint