Кнопка «Нравится» с панелью реакций. Долгое нажатие на кнопку (в вебе — наведение мыши) открывает панель: реакции выскакивают по очереди на пружине, а та, которую держит палец или курсор, увеличивается и раздвигает соседей. После касания реакция перелетает по квадратичной дуге в кнопку и остаётся на ней; короткое нажатие ставит или снимает «Нравится».
Полёт, пружины и покачивание считаются на UI-потоке Reanimated, панель сама сдвигается от краёв экрана. При включённом «Уменьшении движения» реакция ставится сразу, без полёта и раздвигания. Работает на iOS, Android и в вебе.
Поставьте зависимости:
npm install expo-linear-gradient react-native-reanimated react-native-svg react-native-workletspnpm add expo-linear-gradient react-native-reanimated react-native-svg react-native-workletsyarn add expo-linear-gradient react-native-reanimated react-native-svg react-native-workletsbun add expo-linear-gradient react-native-reanimated react-native-svg react-native-workletsСкопируйте эти файлы в src/shared/components/molecules/reaction-arc/:
Проверьте алиас путей. Импорты в коде выше записаны так, как их пишет astra-rn add: @/shared/components, @/shared/utils и @/shared/hooks. Нужен алиас "@/*": ["./src/*"] в tsconfig.json — или поправьте импорты под свои пути.
import React, { useMemo } from "react";
import { StyleSheet, useWindowDimensions, View } from "react-native";
import {
DEFAULT_REACTIONS,
ReactionArc,
type ArcDirection,
} from "@/shared/components/molecules/reaction-arc";
import { Showcase, useCustomize } from "~/showcase";
const CUSTOMIZE = [
{ key: "size", label: "Размер кнопки", type: "slider", min: 36, max: 80, step: 2, unit: "px", default: 56 },
{ key: "count", label: "Реакций в панели", type: "slider", min: 2, max: 6, step: 1, default: 6 },
{ key: "duration", label: "Длительность полёта", type: "slider", min: 200, max: 1500, step: 50, unit: "мс", default: 500 },
{ key: "strength", label: "Изгиб дуги", type: "slider", min: 0, max: 1.5, step: 0.02, default: 0.72 },
{ key: "peak", label: "Вершина дуги", type: "slider", min: 0.05, max: 0.95, step: 0.01, default: 0.28 },
{
key: "direction",
label: "Направление",
type: "select",
options: [
{ value: "ccw", label: "Против часовой" },
{ value: "cw", label: "По часовой" },
{ value: "auto", label: "Авто" },
],
default: "ccw",
},
] as const;
export default function ReactionArcScreen() {
const custom = useCustomize(CUSTOMIZE);
const { width } = useWindowDimensions();
const narrow = width <= 480;
const baseSize = narrow ? Math.round((custom.size * 44) / 56) : custom.size;
const size = Math.max(28, Math.min(baseSize, Math.floor((width - 48) / (custom.count * 1.15))));
const reactions = useMemo(
() =>
custom.count === DEFAULT_REACTIONS.length
? DEFAULT_REACTIONS
: DEFAULT_REACTIONS.slice(0, custom.count),
[custom.count],
);
return (
<Showcase>
<View style={styles.page}>
<View style={styles.stage}>
<ReactionArc
size={size}
reactions={reactions}
duration={custom.duration / 1000}
strength={custom.strength}
peak={custom.peak}
direction={custom.direction as ArcDirection}
/>
</View>
</View>
</Showcase>
);
}
const styles = StyleSheet.create({
page: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: 48,
},
stage: {
width: "100%",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 16,
paddingVertical: 64,
},
});Изгиб дуги задают strength, peak и direction, а onChange сообщает выбранную реакцию (или null, когда отметку сняли).
reactions?Тип readonly IReaction[]
size?Тип number
strength?Тип number
peak?Тип number
duration?Тип number
direction?Тип ArcDirection
onChange?Тип (reaction: IReaction | null) => void
style?Тип StyleProp<ViewStyle>