Как создать анимированную панель заголовка с возможностью прокрутки и 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. Не стесняйтесь настраивать анимацию, добавлять больше интерактивных элементов или корректировать стиль в соответствии с потребностями вашего приложения.