Компоненты/Карточка-флип

Карточка-флип

31 января 2025

Карточка плавно переворачивается в 3D

Установка

npx astra-rn add flip-card
pnpm dlx astra-rn add flip-card
yarn dlx astra-rn add flip-card
bunx --bun astra-rn add flip-card

Файлы

index.tsx
НазадСтопка карточекДалееКарусель с фокусом

Вручную

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

npm install @sbaiahmed1/react-native-blur expo-haptics react-native-reanimated
pnpm add @sbaiahmed1/react-native-blur expo-haptics react-native-reanimated
yarn add @sbaiahmed1/react-native-blur expo-haptics react-native-reanimated
bun add @sbaiahmed1/react-native-blur expo-haptics react-native-reanimated

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

Он импортирует ещё один модуль — astra-rn add кладёт их рядом сам, вручную скопируйте и их:

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

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

import React, { useMemo, useRef, useState, type FC } from "react";
import { FlipCard } from "@/shared/components/base/flip-card";
import { LinearGradient } from "expo-linear-gradient";
import { StyleSheet, View, Text, StatusBar } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useFonts } from "expo-font";
import { Showcase, useAutoplay, useCustomize } from "~/showcase";

const CUSTOMIZE = [
  { key: "name", label: "Название", type: "text", default: "Beachfront Villa", maxLength: 28 },
  { key: "location", label: "Место", type: "text", default: "Malibu, California", maxLength: 32 },
  { key: "price", label: "Цена", type: "text", default: "$385", maxLength: 10 },
  { key: "rating", label: "Рейтинг", type: "text", default: "4.92", maxLength: 6 },
  { key: "gradient1", label: "Градиент 1", type: "color", default: "#FF385C" },
  { key: "gradient2", label: "Градиент 2", type: "color", default: "#E31C5F" },
  { key: "gradient3", label: "Градиент 3", type: "color", default: "#BD1E59" },
  { key: "accent", label: "Кнопка брони", type: "color", default: "#FF385C" },
  { key: "animation", label: "Переворот", type: "select", options: [{ value: "horizontal", label: "Гориз." }, { value: "vertical", label: "Верт." }, { value: "depth", label: "Глубина" }], default: "horizontal" },
  { key: "size", label: "Размер", type: "slider", min: 240, max: 360, step: 5, unit: "px", default: 320 },
  { key: "radius", label: "Скругление", type: "slider", min: 0, max: 48, step: 1, unit: "px", default: 16 },
  { key: "stiffness", label: "Жёсткость", type: "slider", min: 40, max: 400, step: 5, default: 120 },
  { key: "damping", label: "Затухание", type: "slider", min: 4, max: 40, step: 1, default: 12 },
  { key: "holdMs", label: "Показ", type: "slider", min: 500, max: 5000, step: 50, unit: "ms", default: 1350 },
] as const;

export default function App<T extends FC<{}>>():
  | (React.ReactNode & React.JSX.Element & React.ReactElement)
  | null {
  const [fontLoaded] = useFonts({
    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),
  });
  const [flipped, setFlipped] = useState<boolean>(false);
  const custom = useCustomize(CUSTOMIZE);
  const holdRef = useRef(custom.holdMs);
  holdRef.current = custom.holdMs;
  const springConfig = useMemo(
    () => ({ mass: 0.8, stiffness: custom.stiffness, damping: custom.damping }),
    [custom.stiffness, custom.damping],
  );
  const frontColors = useMemo(
    () => [custom.gradient1, custom.gradient2, custom.gradient3] as const,
    [custom.gradient1, custom.gradient2, custom.gradient3],
  );

  useAutoplay(async ({ wait }) => {
    await wait(450);
    setFlipped(true);
    await wait(holdRef.current);
    setFlipped(false);
    await wait(700);
  });

  if (!fontLoaded) return null;

  return (
    <Showcase>
      <SafeAreaView style={styles.safeArea}>
        <StatusBar barStyle="light-content" />
        <FlipCard
          width={custom.size}
          height={custom.size}
          animationDuration={700}
          animation={custom.animation as "horizontal" | "vertical" | "depth"}
          borderRadius={custom.radius}
          enableHaptics
          scaleOnPress={Boolean(true)}
          blurTint="dark"
          blurIntensity={10}
          isFlipped={flipped}
          onFlip={setFlipped}
          springConfig={springConfig}
        >
          <FlipCard.Front>
            <LinearGradient
              style={styles.gradient}
              colors={frontColors}
              start={{ x: 0, y: 0 }}
              end={{ x: 1, y: 1 }}
            />
            <View style={styles.frontContent}>
              <View style={styles.topRow}>
                <View style={styles.guestFavorite}>
                  <Text style={styles.guestFavoriteText}>Guest favorite</Text>
                </View>
                <View style={styles.rating}>
                  <Text style={styles.star}>★</Text>
                  <Text style={styles.ratingText}>{custom.rating}</Text>
                </View>
              </View>
              <View style={styles.mainContent}>
                <Text style={styles.propertyType}>Entire home</Text>
                <Text style={styles.propertyName}>{custom.name}</Text>
                <Text style={styles.location}>{custom.location}</Text>
              </View>
              <View style={styles.bottomRow}>
                <View style={styles.priceBlock}>
                  <Text style={styles.price}>{custom.price}</Text>
                  <Text style={styles.perNight}>night</Text>
                </View>
                <Text style={styles.tapHint}>Tap for details</Text>
              </View>
            </View>
          </FlipCard.Front>
          <FlipCard.Back>
            <LinearGradient
              style={styles.gradient}
              colors={["#222222", "#1a1a1a"]}
              start={{ x: 0, y: 0 }}
              end={{ x: 0, y: 1 }}
            />
            <View style={styles.backContent}>
              <Text style={styles.backTitle}>What this place offers</Text>
              <View style={styles.amenitiesGrid}>
                <View style={styles.amenityItem}>
                  <Text style={styles.amenityText}>Ocean view</Text>
                </View>
                <View style={styles.amenityItem}>
                  <Text style={styles.amenityText}>Pool</Text>
                </View>
                <View style={styles.amenityItem}>
                  <Text style={styles.amenityText}>Fast wifi</Text>
                </View>
                <View style={styles.amenityItem}>
                  <Text style={styles.amenityText}>Free parking</Text>
                </View>
              </View>
              <View style={styles.divider} />
              <View style={styles.statsRow}>
                <View style={styles.stat}>
                  <Text style={styles.statNumber}>4</Text>
                  <Text style={styles.statLabel}>guests</Text>
                </View>
                <View style={styles.stat}>
                  <Text style={styles.statNumber}>2</Text>
                  <Text style={styles.statLabel}>bedrooms</Text>
                </View>
                <View style={styles.stat}>
                  <Text style={styles.statNumber}>2</Text>
                  <Text style={styles.statLabel}>baths</Text>
                </View>
              </View>
              <View style={[styles.reserveButton, { backgroundColor: custom.accent }]}>
                <Text style={styles.reserveText}>Reserve</Text>
              </View>
            </View>
          </FlipCard.Back>
          <FlipCard.Trigger asChild={false} />
        </FlipCard>
      </SafeAreaView>
    </Showcase>
  );
}

const styles = StyleSheet.create({
  safeArea: {
    flex: 1,
    backgroundColor: "#0a0a0a",
    alignItems: "center",
    justifyContent: "center",
  },
  gradient: {
    ...StyleSheet.absoluteFillObject,
  },
  frontContent: {
    flex: 1,
    justifyContent: "space-between",
    padding: 28,
  },
  topRow: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "center",
  },
  guestFavorite: {
    backgroundColor: "rgba(255, 255, 255, 0.95)",
    paddingHorizontal: 14,
    paddingVertical: 8,
    borderRadius: 20,
  },
  guestFavoriteText: {
    fontFamily: "SfProRounded",
    fontSize: 12,
    fontWeight: "600",
    color: "#222222",
  },
  rating: {
    flexDirection: "row",
    alignItems: "center",
    gap: 4,
  },
  star: {
    fontSize: 14,
    color: "#ffffff",
  },
  ratingText: {
    fontFamily: "SfProRounded",
    fontSize: 15,
    fontWeight: "600",
    color: "#ffffff",
  },
  mainContent: {
    gap: 6,
  },
  propertyType: {
    fontFamily: "SfProRounded",
    fontSize: 14,
    color: "rgba(255, 255, 255, 0.7)",
    textTransform: "uppercase",
    letterSpacing: 1,
  },
  propertyName: {
    fontFamily: "SfProRounded",
    fontSize: 36,
    fontWeight: "700",
    color: "#ffffff",
    letterSpacing: -0.5,
  },
  location: {
    fontFamily: "SfProRounded",
    fontSize: 17,
    color: "rgba(255, 255, 255, 0.85)",
  },
  bottomRow: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "flex-end",
  },
  priceBlock: {
    flexDirection: "row",
    alignItems: "baseline",
    gap: 5,
  },
  price: {
    fontFamily: "SfProRounded",
    fontSize: 32,
    fontWeight: "700",
    color: "#ffffff",
  },
  perNight: {
    fontFamily: "SfProRounded",
    fontSize: 16,
    color: "rgba(255, 255, 255, 0.8)",
  },
  tapHint: {
    fontFamily: "SfProRounded",
    fontSize: 13,
    color: "rgba(255, 255, 255, 0.5)",
  },
  backContent: {
    flex: 1,
    padding: 28,
    justifyContent: "space-between",
  },
  backTitle: {
    fontFamily: "SfProRounded",
    fontSize: 20,
    fontWeight: "600",
    color: "#ffffff",
  },
  amenitiesGrid: {
    flexDirection: "row",
    flexWrap: "wrap",
    gap: 12,
    marginTop: 10,
  },
  amenityItem: {
    width: "47%",
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center",
    gap: 12,
    backgroundColor: "rgba(255, 255, 255, 0.08)",
    paddingHorizontal: 16,
    paddingVertical: 10,
    borderRadius: 12,
  },
  amenityIcon: {
    fontSize: 20,
  },
  amenityText: {
    fontFamily: "SfProRounded",
    fontSize: 12,
    color: "#ffffff",
  },
  divider: {
    height: 0.5,
    backgroundColor: "rgba(255, 255, 255, 0.1)",
    marginTop: 10,
  },
  statsRow: {
    flexDirection: "row",
    justifyContent: "space-around",
  },
  stat: {
    alignItems: "center",
    gap: 4,
  },
  statNumber: {
    fontFamily: "SfProRounded",
    fontSize: 16,
    fontWeight: "600",
    color: "#ffffff",
  },
  statLabel: {
    fontFamily: "SfProRounded",
    fontSize: 11.5,
    color: "rgba(255, 255, 255, 0.6)",
  },
  reserveButton: {
    backgroundColor: "#FF385C",
    paddingVertical: 12,
    borderRadius: 12,
    marginTop: 12,
    alignItems: "center",
  },
  reserveText: {
    fontFamily: "SfProRounded",
    fontSize: 12,
    fontWeight: "600",
    color: "#ffffff",
  },
});

Пропсы

Пропс
Описание
width?

Тип number

height?

Тип number

borderRadius?

Тип number

blurIntensity?

Тип number

containerStyle?

Тип StyleProp<ViewStyle>

blurTint?

Тип BlurType

animationDuration?

Approximate settle time of the flip spring, in ms.

Тип number

springConfig?

Тип WithSpringConfig

animation?

Тип TFlipCardAnimation

enableHaptics?

Тип boolean

onFlip?

Тип (isFlipped: boolean) => void

scaleOnPress?

Тип boolean

isFlipped?

Controlled mode: when provided, the card mirrors this value.

Тип boolean

defaultFlipped?

Тип boolean

FlipCardContextValue

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

Тип boolean

flip

Тип () => void

flipTo

Тип (flipped: boolean) => void

width

Тип number

height

Тип number

borderRadius

Тип number

blurIntensity

Тип number

animation

Тип TFlipCardAnimation

progress

0 = front facing, 1 = back facing.

Тип SharedValue<number>

scale

Тип SharedValue<number>

tint

Тип BlurType

scaleEnabled

Тип boolean