Scrollable Search

A pull to search layout with smooth scroll animations

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated react-native-worklets react-native-safe-area-context expo-blur

Copy and paste the following code into your project. component/base/scrollable-search.tsx

// @ts-checkimport React, {  createContext,  useContext,  useRef,  useState,  useMemo,  memo,  useEffect,} from "react";import { BlurView, type BlurViewProps } from "expo-blur";import {  Keyboard,  Platform,  StyleSheet,  TouchableOpacity,  View,  type ViewStyle,} from "react-native";import Animated, {  Extrapolation,  interpolate,  useAnimatedProps,  useAnimatedScrollHandler,  useAnimatedStyle,  useSharedValue,  withSpring,  withTiming,} from "react-native-reanimated";import { SafeAreaView } from "react-native-safe-area-context";import { scheduleOnRN } from "react-native-worklets";import type {  IAnimatedComponent,  IFocusedScreen,  IOverlay,  IScrollableSearch,  IScrollableSearchContext,  IScrollContent,} from "./types";const AnimatedBlurView =  Animated.createAnimatedComponent<BlurViewProps>(BlurView);const ScrollableSearchContext = createContext<IScrollableSearchContext | null>(  null,);const useScrollableSearch = () => {  const context = useContext<IScrollableSearchContext | null>(    ScrollableSearchContext,  );  if (!context) {    throw new Error(      "ScrollableSearch compound components must be rendered within <ScrollableSearch>",    );  }  return context;};const ScrollableSearchRoot: React.FC<IScrollableSearch> &  React.FunctionComponent<IScrollableSearch> = memo<IScrollableSearch>(  ({    children,  }: IScrollableSearch): React.ReactNode &    React.JSX.Element &    React.ReactNode => {    const [isFocused, setIsFocused] = useState<boolean>(false);    const dismissTimeoutRef = useRef<NodeJS.Timeout | null>(null);    const scrollY = useSharedValue<number>(0);    const pullDistance = useSharedValue<number>(0);    const shouldAutoFocus = useSharedValue<boolean>(false);    const onPullToFocusCallback = useRef<(() => void) | null>(null);    const setIsFocusedWithDelay = <T extends boolean>(focused: T) => {      if (dismissTimeoutRef.current) {        clearTimeout(dismissTimeoutRef.current);        dismissTimeoutRef.current = null;      }      if (!focused) {        dismissTimeoutRef.current = setTimeout<[]>(() => {          Keyboard.dismiss();        }, 450);      }      setIsFocused(focused);    };    useEffect(() => {      return () => {        if (dismissTimeoutRef.current) {          clearTimeout(dismissTimeoutRef.current);        }      };    }, []);    const value = useMemo<IScrollableSearchContext>(      () => ({        isFocused,        setIsFocused: setIsFocusedWithDelay,        scrollY,        pullDistance,        shouldAutoFocus,        onPullToFocusCallback,      }),      [isFocused, scrollY, pullDistance, shouldAutoFocus],    );    return (      <ScrollableSearchContext.Provider value={value}>        <View style={styles.wrapper}>{children}</View>      </ScrollableSearchContext.Provider>    );  },);const ScrollContent: React.FC<IScrollContent> &  React.FunctionComponent<IScrollContent> = memo<IScrollContent>(  ({    children,    pullThreshold = 80,  }: IScrollContent): React.ReactNode & React.JSX.Element & React.ReactNode => {    const {      isFocused,      scrollY,      pullDistance,      shouldAutoFocus,      onPullToFocusCallback,    } = useScrollableSearch();    const triggerFocus = () => {      if (onPullToFocusCallback.current) {        onPullToFocusCallback.current();      }    };    const onScroll = useAnimatedScrollHandler({      onScroll: (event) => {        const offsetY = event.contentOffset.y;        scrollY.value = offsetY;        if (offsetY < 0) {          pullDistance.value = Math.abs(offsetY);          if (pullDistance.value > pullThreshold && !shouldAutoFocus.value) {            shouldAutoFocus.value = true;            scheduleOnRN(triggerFocus);          }        } else {          pullDistance.value = 0;        }      },      onEndDrag: () => {        "worklet";        shouldAutoFocus.value = false;      },    });    const animatedStyle = useAnimatedStyle<Pick<ViewStyle, "opacity">>(() => {      return {        opacity: 1,      };    });    return (      <Animated.View        style={[StyleSheet.absoluteFill, animatedStyle]}        pointerEvents={isFocused ? "none" : "auto"}      >        <Animated.ScrollView          style={styles.scrollView}          contentContainerStyle={styles.scrollContent}          showsVerticalScrollIndicator={false}          onScroll={onScroll}          scrollEventThrottle={8}          bounces={true}        >          {children}        </Animated.ScrollView>      </Animated.View>    );  },);const AnimatedComponent: React.FC<IAnimatedComponent> &  React.FunctionComponent<IAnimatedComponent> = memo<IAnimatedComponent>(  ({    children,    focusedOffset = -90,    unfocusedOffset = 30,    enablePullEffect = true,    onPullToFocus,    springConfig = {      damping: 18,      stiffness: 120,      mass: 0.6,    },  }: IAnimatedComponent): React.ReactNode &    React.JSX.Element &    React.ReactNode => {    const { isFocused, scrollY, pullDistance, onPullToFocusCallback } =      useScrollableSearch();    useEffect(() => {      if (onPullToFocus) {        onPullToFocusCallback.current = onPullToFocus;      }      return () => {        onPullToFocusCallback.current = null;      };    }, [onPullToFocus, onPullToFocusCallback]);    const animatedSearchStylez = useAnimatedStyle<      Pick<ViewStyle, "transform" | "shadowOpacity">    >(() => {      const scale = enablePullEffect        ? interpolate(            pullDistance.value,            [0, 60, 120],            [1, 1.02, 1.05],            Extrapolation.CLAMP,          )        : 1;      const shadowOpacity = enablePullEffect        ? interpolate(            pullDistance.value,            [0, 60],            [0.05, 0.2],            Extrapolation.CLAMP,          )        : 0.05;      const translateY = interpolate(        scrollY.value,        [0, scrollY.value],        [0, -scrollY.value],        Extrapolation.CLAMP,      );      return {        transform: [{ scale }, { translateY }],        shadowOpacity,      };    });    const animatedContainerStylez = useAnimatedStyle<      Pick<ViewStyle, "opacity" | "transform">    >(() => {      const baseOffset = isFocused ? focusedOffset : unfocusedOffset;      const opacity = interpolate(        scrollY.value,        [0, 100],        [1, 0],        Extrapolation.CLAMP,      );      const translateY = withSpring(baseOffset, springConfig);      return {        transform: [{ translateY }],        opacity,      };    }, [isFocused]);    return (      <Animated.View        style={[styles.animatedContainer, animatedContainerStylez]}      >        <SafeAreaView edges={["top"]}>          <Animated.View style={[animatedSearchStylez]}>            {children}          </Animated.View>        </SafeAreaView>      </Animated.View>    );  },);const Overlay: React.FC<IOverlay> & React.FunctionComponent<IOverlay> =  memo<IOverlay>(    ({      children,      onPress,      enableBlur = true,      blurTint = "dark",      maxBlurIntensity = 80,    }: IOverlay): React.ReactNode & React.JSX.Element & React.ReactNode => {      const { isFocused, pullDistance, setIsFocused } = useScrollableSearch();      const animatedBlurProps = useAnimatedProps(() => {        if (isFocused) {          return {            intensity: maxBlurIntensity,          };        }        const intensity = interpolate(          pullDistance.value,          [0, 20, 80],          [0, 30, maxBlurIntensity],          Extrapolation.CLAMP,        );        return {          intensity,        };      }, [isFocused]);      const animatedStyle = useAnimatedStyle(() => {        if (isFocused) {          return {            opacity: withTiming(1, { duration: 350 }),          };        }        const opacity = interpolate(          pullDistance.value,          [0, 10],          [0, 1],          Extrapolation.CLAMP,        );        return {          opacity:            pullDistance.value > 0 ? opacity : withTiming(0, { duration: 400 }),        };      }, [isFocused]);      const handlePress = () => {        if (isFocused) {          setIsFocused(false);        }        onPress?.();      };      return (        <Animated.View          style={[styles.overlay, animatedStyle]}          pointerEvents={isFocused ? "auto" : "none"}        >          <TouchableOpacity            style={StyleSheet.absoluteFill}            activeOpacity={1}            onPress={handlePress}          >            {enableBlur ? (              Platform.OS === "ios" ? (                <AnimatedBlurView                  style={StyleSheet.absoluteFill}                  tint={blurTint}                  animatedProps={animatedBlurProps}                >                  {children}                </AnimatedBlurView>              ) : (                <View                  style={[                    StyleSheet.absoluteFill,                    {                      backgroundColor: "rgba(0,0,0,1)",                    },                  ]}                >                  {children}                </View>              )            ) : (              <Animated.View style={StyleSheet.absoluteFill}>                {children}              </Animated.View>            )}          </TouchableOpacity>        </Animated.View>      );    },  );const FocusedScreen: React.FC<IFocusedScreen> &  React.FunctionComponent<IFocusedScreen> = memo<IFocusedScreen>(  ({    children,  }: IFocusedScreen): React.ReactNode & React.JSX.Element & React.ReactNode => {    const { isFocused } = useScrollableSearch();    const animatedStylez = useAnimatedStyle(() => {      return {        opacity: withTiming(isFocused ? 1 : 0, {          duration: isFocused ? 350 : 400,        }),      };    }, [isFocused]);    return (      <Animated.View        style={[StyleSheet.absoluteFill, animatedStylez]}        pointerEvents={isFocused ? "box-none" : "none"}      >        {children}      </Animated.View>    );  },);const ScrollableSearch = Object.assign(  memo<IScrollableSearch>(ScrollableSearchRoot),  {    ScrollContent,    AnimatedComponent,    Overlay,    FocusedScreen,  },);export { useScrollableSearch, ScrollableSearch };const styles = StyleSheet.create({  wrapper: {    flex: 1,    backgroundColor: "#0A0A0A",  },  scrollView: {    flex: 1,  },  scrollContent: {    paddingTop: 100,    paddingBottom: 20,  },  animatedContainer: {    position: "absolute",    top: 90,    left: 0,    right: 0,    zIndex: 100,    backgroundColor: "transparent",  },  overlay: {    position: "absolute",    top: 0,    left: 0,    right: 0,    bottom: 0,    zIndex: 50,  },});

Usage

import React from "react";import { StyleSheet, Text, View, Pressable, ScrollView } from "react-native";import { SafeAreaView } from "react-native-safe-area-context";import { SymbolView } from "expo-symbols";import { useFonts } from "expo-font";import {  ScrollableSearch,  useScrollableSearch,} from "@/components/base/scrollable-search";const PLACES = [  { id: "1", title: "Tokyo", subtitle: "Japan", symbol: "building.2.fill" },  { id: "2", title: "Kyoto", subtitle: "Japan", symbol: "leaf.fill" },  {    id: "3",    title: "Mount Fuji",    subtitle: "Nature",    symbol: "mountain.2.fill",  },  { id: "4", title: "Osaka", subtitle: "Japan", symbol: "sparkles" },];const RECENT = ["Tokyo", "Kyoto", "Osaka"];const SearchBar = ({ fontLoaded }: { fontLoaded: boolean }) => {  const { setIsFocused, isFocused } = useScrollableSearch();  return (    <ScrollableSearch.AnimatedComponent      onPullToFocus={() => setIsFocused(true)}      focusedOffset={-70}      unfocusedOffset={53}    >      <Pressable        style={styles.searchBar}        onPress={() => setIsFocused(!isFocused)}      >        <SymbolView name="magnifyingglass" size={18} tintColor="#666" />        <Text          style={[            styles.searchPlaceholder,            fontLoaded && { fontFamily: "SfProRounded" },          ]}        >          Search destinations...        </Text>      </Pressable>    </ScrollableSearch.AnimatedComponent>  );};const Content = ({ fontLoaded }: { fontLoaded: boolean }) => {  const { setIsFocused } = useScrollableSearch();  return (    <>      <ScrollableSearch.ScrollContent>        <SafeAreaView>          <View style={styles.header}>            <Text              style={[                styles.title,                fontLoaded && { fontFamily: "HelveticaNowDisplay" },              ]}            >              Explore            </Text>            <Text              style={[                styles.subtitle,                fontLoaded && { fontFamily: "SfProRounded" },              ]}            >              Find your next adventure            </Text>          </View>          <View style={styles.section}>            <View style={styles.sectionHeader}>              <SymbolView name="star.fill" size={16} tintColor="#fbbf24" />              <Text                style={[                  styles.sectionTitle,                  fontLoaded && { fontFamily: "SfProRounded" },                ]}              >                Popular              </Text>            </View>            {PLACES.map((place) => (              <Pressable key={place.id} style={styles.card}>                <View style={styles.cardIcon}>                  <SymbolView                    name={place.symbol as any}                    size={20}                    tintColor="#fff"                  />                </View>                <View style={styles.cardContent}>                  <Text                    style={[                      styles.cardTitle,                      fontLoaded && { fontFamily: "HelveticaNowDisplay" },                    ]}                  >                    {place.title}                  </Text>                  <Text                    style={[                      styles.cardSubtitle,                      fontLoaded && { fontFamily: "SfProRounded" },                    ]}                  >                    {place.subtitle}                  </Text>                </View>                <SymbolView name="chevron.right" size={14} tintColor="#444" />              </Pressable>            ))}          </View>        </SafeAreaView>      </ScrollableSearch.ScrollContent>      <ScrollableSearch.Overlay onPress={() => setIsFocused(false)}>        <ScrollableSearch.FocusedScreen>          <SafeAreaView edges={["top"]} style={styles.focusedContainer}>            <ScrollView              style={styles.focusedScroll}              showsVerticalScrollIndicator={false}            >              <View style={styles.focusedSection}>                <View style={styles.focusedHeader}>                  <SymbolView name="clock.fill" size={16} tintColor="#888" />                  <Text                    style={[                      styles.focusedTitle,                      fontLoaded && { fontFamily: "SfProRounded" },                    ]}                  >                    Recent Searches                  </Text>                </View>                {RECENT.map((item, index) => (                  <Pressable key={index} style={styles.recentItem}>                    <SymbolView                      name="magnifyingglass"                      size={16}                      tintColor="#666"                    />                    <Text                      style={[                        styles.recentText,                        fontLoaded && { fontFamily: "SfProRounded" },                      ]}                    >                      {item}                    </Text>                    <SymbolView                      name="arrow.up.left"                      size={14}                      tintColor="#444"                    />                  </Pressable>                ))}              </View>              <View style={styles.tipCard}>                <SymbolView name="sparkles" size={18} tintColor="#a78bfa" />                <View style={styles.tipContent}>                  <Text                    style={[                      styles.tipTitle,                      fontLoaded && { fontFamily: "SfProRounded" },                    ]}                  >                    Pro Tip                  </Text>                  <Text                    style={[                      styles.tipText,                      fontLoaded && { fontFamily: "SfProRounded" },                    ]}                  >                    Pull down to quickly access search                  </Text>                </View>              </View>            </ScrollView>          </SafeAreaView>        </ScrollableSearch.FocusedScreen>      </ScrollableSearch.Overlay>      <SearchBar fontLoaded={fontLoaded} />    </>  );};export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  return (    <ScrollableSearch>      <Content fontLoaded={fontLoaded} />    </ScrollableSearch>  );}const styles = StyleSheet.create({  header: {    paddingHorizontal: 20,    paddingTop: 16,    paddingBottom: 28,    bottom: 60,  },  title: {    fontSize: 34,    fontWeight: "700",    color: "#fff",    marginBottom: 4,  },  subtitle: {    fontSize: 15,    color: "#666",  },  section: {    paddingHorizontal: 20,  },  sectionHeader: {    flexDirection: "row",    alignItems: "center",    gap: 8,    marginBottom: 16,  },  sectionTitle: {    fontSize: 17,    fontWeight: "600",    color: "#fff",  },  card: {    flexDirection: "row",    alignItems: "center",    backgroundColor: "#121212",    padding: 16,    borderRadius: 16,    marginBottom: 10,    gap: 14,  },  cardIcon: {    width: 44,    height: 44,    borderRadius: 12,    backgroundColor: "#252525",    justifyContent: "center",    alignItems: "center",  },  cardContent: {    flex: 1,    gap: 2,  },  cardTitle: {    fontSize: 17,    fontWeight: "600",    color: "#fff",  },  cardSubtitle: {    fontSize: 14,    color: "#666",  },  searchBar: {    flexDirection: "row",    alignItems: "center",    backgroundColor: "#1a1a1a",    marginHorizontal: 16,    paddingHorizontal: 16,    paddingVertical: 14,    borderRadius: 14,    gap: 10,    borderWidth: 1,    borderColor: "#252525",  },  searchPlaceholder: {    flex: 1,    fontSize: 16,    color: "#555",  },  focusedContainer: {    flex: 1,  },  focusedScroll: {    flex: 1,    paddingTop: 90,  },  focusedSection: {    paddingHorizontal: 20,    marginBottom: 24,  },  focusedHeader: {    flexDirection: "row",    alignItems: "center",    gap: 8,    marginBottom: 14,  },  focusedTitle: {    fontSize: 15,    fontWeight: "600",    color: "#888",  },  recentItem: {    flexDirection: "row",    alignItems: "center",    backgroundColor: "#1a1a1a",    padding: 14,    borderRadius: 12,    marginBottom: 8,    gap: 12,    borderWidth: 1,    borderColor: "#252525",  },  recentText: {    flex: 1,    fontSize: 15,    color: "#fff",  },  tipCard: {    flexDirection: "row",    backgroundColor: "rgba(167, 139, 250, 0.1)",    marginHorizontal: 20,    padding: 16,    borderRadius: 14,    gap: 12,    borderWidth: 1,    borderColor: "rgba(167, 139, 250, 0.2)",  },  tipContent: {    flex: 1,    gap: 2,  },  tipTitle: {    fontSize: 14,    fontWeight: "600",    color: "#a78bfa",  },  tipText: {    fontSize: 13,    color: "#888",  },});

Props

IScrollableSearchContext

React Native Reanimated
React Native Worklets
React Native Safe Area Context
Expo Blur