Scroll-Responsive Animated Header Bar with Expo Router

A common UI pattern you’ll see in mobile apps is the “native” header dynamically transitioning elements in and out or animating colors as you scroll up and down. Using Expo Router’s Stack component, we can create a reusable component that abstracts muc…


This content originally appeared on DEV Community and was authored by Will

A common UI pattern you'll see in mobile apps is the "native" header dynamically transitioning elements in and out or animating colors as you scroll up and down. Using Expo Router's Stack component, we can create a reusable component that abstracts much of the logic while maintaining flexibility through prop customisation.

We'll be creating a component called AnimatedHeaderScreen which you can quickly wrap around screens to add this functionality. While customisation will depend on specific needs, we'll be animating optional left/right icons and changing the background color, along with applying small details like a border.

What we'll be building

Demo of animated header

Prerequisites

This tutorial assumes you're using Expo Router in your project, as we'll be utilising components like Stack.Screen. If you want to start with a fresh install, you can use the following command to create a new TypeScript project with Expo:

npx create-expo-app@latest

Diving into the implementation

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,
  },
});

How It Works

Tracking Scroll Position
We use an Animated.Value to keep tabs on how far the user has scrolled:

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

This value updates as the user scrolls, which we'll use to drive our animations.

Smooth Transitions with Interpolation
We use interpolate to map the scroll position to different style properties. For example:

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

This creates a smooth color change for the header background as you scroll from 0 to 50 pixels. The clamp part just makes sure the color doesn't keep changing beyond what we've set.

Applying Animated Styles
We use these interpolated values in our components with Animated.View and inline styles:

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

This lets the header update its look based on how far you've scrolled.

Animating Optional Elements
For things like icons, we only apply animations if they're actually there:

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

This way, icons fade in smoothly, but only if you've included them as props.

Handling Scroll Events
We use Animated.event to connect scroll events directly to our scrollY value:

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

⚠️ Note: Make sure you have useNativeDriver set to false or you'll encounter the error: "_this.props.onScroll is not a function (it is Object)". This occurs because the native driver can only handle a subset of styles that can be animated on the native side. We're animating non-compatible styles like backgroundColor, which requires JavaScript based animations.

Usage

To use the AnimatedHeaderScreen, simply wrap your screen content with it:

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",
  },
});

That's it! You've now got a solid foundation for an animated header in your Expo Router app. Feel free to tweak the animations, add more interactive elements, or adjust the styling to fit your app's needs.


This content originally appeared on DEV Community and was authored by Will


Print Share Comment Cite Upload Translate Updates
APA

Will | Sciencx (2024-08-21T15:52:10+00:00) Scroll-Responsive Animated Header Bar with Expo Router. Retrieved from https://www.scien.cx/2024/08/21/scroll-responsive-animated-header-bar-with-expo-router/

MLA
" » Scroll-Responsive Animated Header Bar with Expo Router." Will | Sciencx - Wednesday August 21, 2024, https://www.scien.cx/2024/08/21/scroll-responsive-animated-header-bar-with-expo-router/
HARVARD
Will | Sciencx Wednesday August 21, 2024 » Scroll-Responsive Animated Header Bar with Expo Router., viewed ,<https://www.scien.cx/2024/08/21/scroll-responsive-animated-header-bar-with-expo-router/>
VANCOUVER
Will | Sciencx - » Scroll-Responsive Animated Header Bar with Expo Router. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2024/08/21/scroll-responsive-animated-header-bar-with-expo-router/
CHICAGO
" » Scroll-Responsive Animated Header Bar with Expo Router." Will | Sciencx - Accessed . https://www.scien.cx/2024/08/21/scroll-responsive-animated-header-bar-with-expo-router/
IEEE
" » Scroll-Responsive Animated Header Bar with Expo Router." Will | Sciencx [Online]. Available: https://www.scien.cx/2024/08/21/scroll-responsive-animated-header-bar-with-expo-router/. [Accessed: ]
rf:citation
» Scroll-Responsive Animated Header Bar with Expo Router | Will | Sciencx | https://www.scien.cx/2024/08/21/scroll-responsive-animated-header-bar-with-expo-router/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.