DevGang
Авторизоваться

Как создать анимированную панель заголовка с возможностью прокрутки и Expo Router

Обычный шаблон пользовательского интерфейса, который вы увидите в мобильных приложениях, - это «native» заголовок, в котором элементы динамически перемещаются внутрь и наружу или анимируются цвета при прокрутке вверх и вниз. Используя компонент Stack Expo Router, мы можем создать повторно используемый компонент, который абстрагирует большую часть логики, сохраняя при этом гибкость за счет настройки prop.

Мы создадим компонент под названием AnimatedHeaderScreen, который вы сможете быстро перемещать по экранам, чтобы добавить эту функциональность. Хотя настройка будет зависеть от конкретных потребностей, мы будем анимировать дополнительные значки слева / справа и изменять цвет фона, а также добавлять мелкие детали, такие как рамка.

Продемонстрируем результат, к которому мы будем стремиться:

Предпосылки

В этом руководстве предполагается, что вы используете Expo Router в своем проекте, поскольку мы будем использовать такие компоненты, как Stack.Screen. Если вы хотите начать с новой установки, вы можете использовать следующую команду для создания нового проекта TypeScript с Expo:

npx create-expo-app@latest

Реализация

import React, { useRef, ReactNode, useCallback } from "react";
import {
  View,
  Animated,
  ScrollView,
  StyleSheet,
  TouchableOpacity,
} from "react-native";
import { Stack } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";

type AnimatedHeaderScreenProps = {
  children: ReactNode;
  title?: string;
  leftIcon?: {
    name: keyof typeof Ionicons.glyphMap;
    onPress: () => void;
  };
  rightIcon?: {
    name: keyof typeof Ionicons.glyphMap;
    onPress: () => void;
  };
};

const colors = {
  background: "#000000",
  backgroundScrolled: "#1C1C1D",
  headerBorder: "#2C2C2E",
  borderColor: "#3A3A3C",
  text: "#FFFFFF",
  tint: "#4A90E2",
};

export default function AnimatedHeaderScreen({
  title,
  children,
  leftIcon,
  rightIcon,
}: AnimatedHeaderScreenProps) {
  const scrollY = useRef(new Animated.Value(0)).current;
  const insets = useSafeAreaInsets();

  const headerBackgroundColor = scrollY.interpolate({
    inputRange: [0, 50],
    outputRange: [colors.background, colors.backgroundScrolled],
    extrapolate: "clamp",
  });

  const handleScroll = Animated.event(
    [{ nativeEvent: { contentOffset: { y: scrollY } } }],
    { useNativeDriver: false }
  );

  const headerBorderWidth = scrollY.interpolate({
    inputRange: [0, 50],
    outputRange: [0, StyleSheet.hairlineWidth],
    extrapolate: "clamp",
  });

  const rightIconOpacity = rightIcon
    ? scrollY.interpolate({
        inputRange: [30, 50],
        outputRange: [0, 1],
        extrapolate: "clamp",
      })
    : 0;

  const rightIconTranslateY = rightIcon
    ? scrollY.interpolate({
        inputRange: [30, 50],
        outputRange: [10, 0],
        extrapolate: "clamp",
      })
    : 0;

  return (
    <>
      <Stack.Screen
        options={{
          headerShown: true,
          headerTitleAlign: "center",
          headerTitle: title,
          headerLeft: leftIcon
            ? () => (
                <Animated.View
                  style={{
                    opacity: rightIconOpacity,
                    transform: [{ translateY: rightIconTranslateY }],
                  }}
                >
                  <TouchableOpacity onPress={leftIcon.onPress}>
                    <Ionicons
                      name={leftIcon.name}
                      size={24}
                      color={colors.tint}
                      style={styles.leftIcon}
                    />
                  </TouchableOpacity>
                </Animated.View>
              )
            : undefined,
          headerRight: rightIcon
            ? () => (
                <Animated.View
                  style={{
                    opacity: rightIconOpacity,
                    transform: [{ translateY: rightIconTranslateY }],
                  }}
                >
                  <TouchableOpacity onPress={rightIcon.onPress}>
                    <Ionicons
                      name={rightIcon.name}
                      size={24}
                      color={colors.tint}
                      style={styles.rightIcon}
                    />
                  </TouchableOpacity>
                </Animated.View>
              )
            : undefined,
          headerBackground: () => (
            <Animated.View
              style={[
                StyleSheet.absoluteFill,
                styles.headerBackground,
                {
                  backgroundColor: headerBackgroundColor,
                  borderBottomColor: colors.borderColor,
                  borderBottomWidth: headerBorderWidth,
                },
              ]}
            />
          ),
        }}
      />

      <ScrollView
        style={styles.scrollView}
        contentContainerStyle={[
          styles.scrollViewContent,
          { paddingBottom: insets.bottom },
        ]}
        onScroll={handleScroll}
        scrollEventThrottle={16}
      >
        <View style={styles.content}>{children}</View>
      </ScrollView>
    </>
  );
}

const styles = StyleSheet.create({
  scrollView: {
    flex: 1,
  },
  scrollViewContent: {
    flexGrow: 1,
  },
  content: {
    flex: 1,
    paddingHorizontal: 8,
    paddingTop: 8,
  },
  headerBackground: {
    borderBottomWidth: 0,
  },
  leftIcon: {
    marginLeft: 16,
  },
  rightIcon: {
    marginRight: 16,
  },
});

Как это работает

Отслеживание позиции прокрутки

Мы используем , Animated.Value чтобы следить за тем, как далеко прокрутил страницу пользователь:

const scrollY = useRef(new Animated.Value(0)).current;

Это значение обновляется по мере прокрутки страницы пользователем, и мы будем использовать его для управления анимацией.

Плавные переходы с интерполяцией

Мы используем interpolate для сопоставления позиции прокрутки с различными свойствами стиля. Например:

const headerBackgroundColor = scrollY.interpolate({
  inputRange: [0, 50],
  outputRange: [colors.background, colors.backgroundScrolled],
  extrapolate: "clamp",
});

Это создает плавное изменение цвета фона заголовка при прокрутке от 0 до 50 пикселей. Эта clamp часть просто гарантирует, что цвет не будет меняться дальше того, что мы установили.

Применение анимированных стилей

Мы используем эти интерполированные значения в наших компонентах со Animated.View встроенными стилями:

<Animated.View
  style={[
    StyleSheet.absoluteFill,
    styles.headerBackground,
    {
      backgroundColor: headerBackgroundColor,
      borderBottomColor: colors.borderColor,
      borderBottomWidth: headerBorderWidth,
    },
  ]}
/>

Это позволяет заголовку обновлять свой вид в зависимости от того, насколько далеко вы прокрутили страницу.

Анимация необязательных элементов

Для таких вещей, как значки, мы применяем анимацию только в том случае, если они действительно есть:

const rightIconOpacity = rightIcon
  ? scrollY.interpolate({
      inputRange: [30, 50],
      outputRange: [0, 1],
      extrapolate: "clamp",
    })
  : 0;

Таким образом, значки будут появляться плавно, но только если вы включили их в качестве реквизита.

Обработка событий прокрутки

Мы используем Animated.event для прямого подключения событий прокрутки к нашему scrollY значению:

const handleScroll = Animated.event(
  [{ nativeEvent: { contentOffset: { y: scrollY } } }],
  { useNativeDriver: false }
);

Примечание: Убедитесь, что вы установили useNativeDriver, false иначе вы столкнетесь с ошибкой: "_this.props.onScroll is not a function (it is Object)". Это происходит, потому что собственный драйвер может обрабатывать только подмножество стилей, которые могут быть анимированы на собственной стороне. Мы анимируем несовместимые стили, такие как backgroundColor, для которых требуются анимации на основе JavaScript.

Использование

Чтобы использовать AnimatedHeaderScreen, просто заключите в него содержимое экрана:

import { Alert, StyleSheet, Text, View } from "react-native";
import AnimatedHeaderScreen from "@/components/AnimatedHeaderScreen";

export default function HomeScreen() {
  return (
    <AnimatedHeaderScreen
      title="Lorem"
      rightIcon={{
        name: "search",
        onPress: () => Alert.alert("Handle search here..."),
      }}
    >
      {/* // Mock cards to fill out the screen... */}
      {Array.from({ length: 20 }, (_, index) => index + 1).map((item) => (
        <View
          style={[
            styles.card,
            { backgroundColor: item % 2 === 0 ? "#4A90E2" : "#67B8E3" },
          ]}
          key={item}
        >
          <Text style={styles.text}>{item}</Text>
        </View>
      ))}
    </AnimatedHeaderScreen>
  );
}

const styles = StyleSheet.create({
  card: {
    height: 80,
    elevation: 6,
    marginTop: 16,
    shadowRadius: 4,
    borderRadius: 12,
    shadowOpacity: 0.1,
    marginHorizontal: 8,
    alignItems: "center",
    justifyContent: "center",
    shadowOffset: { width: 0, height: 3 },
  },
  text: {
    color: "#FFF",
    fontSize: 16,
    fontWeight: "bold",
  },
});

Теперь у вас есть прочная основа для анимированного заголовка в приложении Expo Router. Не стесняйтесь настраивать анимацию, добавлять больше интерактивных элементов или корректировать стиль в соответствии с потребностями вашего приложения.

Источник:

#React #TypeScript
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться