Компоненты/Поиск потягом

Поиск потягом

31 января 2025

Поиск открывается потягом вниз, с плавными анимациями прокрутки

Установка

npx astra-rn add scrollable-search
pnpm dlx astra-rn add scrollable-search
yarn dlx astra-rn add scrollable-search
bunx --bun astra-rn add scrollable-search

Файлы

index.tsx
НазадРаскрытие при прокруткеДалееСтрока поиска

Вручную

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

npm install expo-blur react-native-reanimated react-native-safe-area-context react-native-worklets
pnpm add expo-blur react-native-reanimated react-native-safe-area-context react-native-worklets
yarn add expo-blur react-native-reanimated react-native-safe-area-context react-native-worklets
bun add expo-blur react-native-reanimated react-native-safe-area-context react-native-worklets

Скопируйте эти файлы в src/shared/components/base/scrollable-search/:

Проверьте алиас путей. Импорты в коде выше записаны так, как их пишет astra-rn add: @/shared/components, @/shared/utils и @/shared/hooks. Нужен алиас "@/*": ["./src/*"] в tsconfig.json — или поправьте импорты под свои пути.

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

import React from "react";
import {
  Platform,
  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 { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import {
  ScrollableSearch,
  useScrollableSearch,
} from "@/shared/components/base/scrollable-search";
import { Easing, withSequence, withTiming } from "react-native-reanimated";
import { Showcase, useAutoplay, useCustomize } from "~/showcase";

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 WEB_ICON_FONTS =
  Platform.OS === "web"
    ? { ...Ionicons.font, ...MaterialCommunityIcons.font }
    : {};

const RECENT = ["Tokyo", "Kyoto", "Osaka"];

const CUSTOMIZE = [
  { key: "title", label: "Заголовок", type: "text", default: "Explore", maxLength: 24 },
  { key: "subtitle", label: "Подзаголовок", type: "text", default: "Find your next adventure", maxLength: 40 },
  { key: "placeholder", label: "Подсказка поиска", type: "text", default: "Search destinations...", maxLength: 32 },
  { key: "section", label: "Раздел", type: "text", default: "Popular", maxLength: 20 },
  { key: "recentTitle", label: "Недавние", type: "text", default: "Recent Searches", maxLength: 24 },
  { key: "cardColor", label: "Цвет карточек", type: "color", default: "#121212" },
  { key: "barColor", label: "Цвет поиска", type: "color", default: "#1a1a1a" },
  { key: "starColor", label: "Цвет звезды", type: "color", default: "#fbbf24" },
] as const;

function useDemoCustomize() {
  return useCustomize(CUSTOMIZE);
}

type TCustom = ReturnType<typeof useDemoCustomize>;

const SearchBar = ({
  fontLoaded,
  custom,
}: {
  fontLoaded: boolean;
  custom: TCustom;
}) => {
  const { setIsFocused, isFocused, pullDistance } = useScrollableSearch();

  useAutoplay(async ({ wait }) => {
    try {
      await wait(300);
      pullDistance.value = withSequence(
        withTiming(10, { duration: 300, easing: Easing.inOut(Easing.quad) }),
        withTiming(80, { duration: 300, easing: Easing.out(Easing.quad) }),
      );
      await wait(600);
      setIsFocused(true);
      pullDistance.value = withTiming(0, {
        duration: 320,
        easing: Easing.out(Easing.cubic),
      });
      await wait(1500);
      setIsFocused(false);
      await wait(900);
      setIsFocused(true);
      await wait(1000);
      setIsFocused(false);
      await wait(1000);
    } finally {
      if (pullDistance.value > 0) {
        pullDistance.value = withTiming(0, { duration: 250 });
      }
    }
  });

  return (
    <ScrollableSearch.AnimatedComponent
      onPullToFocus={() => setIsFocused(true)}
      focusedOffset={-70}
      unfocusedOffset={53}
    >
      <Pressable
        style={[styles.searchBar, { backgroundColor: custom.barColor }]}
        onPress={() => setIsFocused(!isFocused)}
      >
        <SymbolView name="magnifyingglass" size={18} tintColor="#666" />
        <Text
          style={[
            styles.searchPlaceholder,
            fontLoaded && { fontFamily: "SfProRounded" },
          ]}
        >
          {custom.placeholder}
        </Text>
      </Pressable>
    </ScrollableSearch.AnimatedComponent>
  );
};

const Content = ({
  fontLoaded,
  custom,
}: {
  fontLoaded: boolean;
  custom: TCustom;
}) => {
  const { setIsFocused } = useScrollableSearch();

  return (
    <>
      <ScrollableSearch.ScrollContent>
        <SafeAreaView>
          <View style={styles.header}>
            <Text
              style={[
                styles.title,
                fontLoaded && { fontFamily: "HelveticaNowDisplay" },
              ]}
            >
              {custom.title}
            </Text>
            <Text
              style={[
                styles.subtitle,
                fontLoaded && { fontFamily: "SfProRounded" },
              ]}
            >
              {custom.subtitle}
            </Text>
          </View>

          <View style={styles.section}>
            <View style={styles.sectionHeader}>
              <SymbolView name="star.fill" size={16} tintColor={custom.starColor} />
              <Text
                style={[
                  styles.sectionTitle,
                  fontLoaded && { fontFamily: "SfProRounded" },
                ]}
              >
                {custom.section}
              </Text>
            </View>

            {PLACES.map((place) => (
              <Pressable key={place.id} style={[styles.card, { backgroundColor: custom.cardColor }]}>
                <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" },
                    ]}
                  >
                    {custom.recentTitle}
                  </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>
            </ScrollView>
          </SafeAreaView>
        </ScrollableSearch.FocusedScreen>
      </ScrollableSearch.Overlay>

      <SearchBar fontLoaded={fontLoaded} custom={custom} />
    </>
  );
};

export default function App() {
  const custom = useDemoCustomize();
  const [fontLoaded] = useFonts({
    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),
    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),
    ...WEB_ICON_FONTS,
  });

  return (
    <Showcase>
      <ScrollableSearch>
        {fontLoaded ? <Content fontLoaded={fontLoaded} custom={custom} /> : null}
      </ScrollableSearch>
    </Showcase>
  );
}

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

Пропсы

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

Тип React.ReactNode

pullThreshold?

Тип number

IScrollableSearchContext

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

Тип boolean

setIsFocused

Тип (focused: boolean) => void

scrollY

Тип SharedValue<number>

pullDistance

Тип SharedValue<number>

shouldAutoFocus

Тип SharedValue<boolean>

onPullToFocusCallback

Тип React.MutableRefObject<(() => void) | null>