From 45e60a830687ab75be5d4383bc3017a24880c212 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 20:13:30 +0500 Subject: [PATCH] feat(ux): spring-driven press feedback on PressableCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the instant CSS active:scale snap with a reanimated spring scale on pressIn/pressOut (0.97 ↔ 1, snappy low-overshoot spring). Every tappable card row — list items, dashboard tiles, home cards — now has the soft tactile press response top-tier apps use, consistently and in one place. Safe inside FlashList rows: unlike entering animations, press springs don't re-trigger on cell recycling. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/ui/PressableCard.tsx | 49 +++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/components/ui/PressableCard.tsx b/components/ui/PressableCard.tsx index a238779..96d1b99 100644 --- a/components/ui/PressableCard.tsx +++ b/components/ui/PressableCard.tsx @@ -1,8 +1,19 @@ import React from "react"; import { Pressable, type PressableProps } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from "react-native-reanimated"; import * as Haptics from "expo-haptics"; import { cn } from "~/lib/utils"; +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +// A snappy-but-soft spring: settles quickly without overshooting enough to +// feel bouncy. Shared by every card so press feedback is identical everywhere. +const PRESS_SPRING = { damping: 18, stiffness: 320, mass: 0.5 } as const; + export interface PressableCardProps extends PressableProps { className?: string; children: React.ReactNode; @@ -12,17 +23,24 @@ export interface PressableCardProps extends PressableProps { /** * A card-styled Pressable with the tactile feedback users expect from a - * top-tier app: a subtle scale-down + shadow/opacity change on press, plus an - * optional light haptic. Use for any tappable card row (list items, dashboard - * tiles) so press affordance is consistent everywhere. + * top-tier app: a spring-driven scale-down on press (not an instant snap), + * a subtle dim, plus an optional light haptic. Use for any tappable card row + * (list items, dashboard tiles) so press affordance is consistent everywhere. */ export function PressableCard({ className, children, haptic = true, onPress, + onPressIn, + onPressOut, ...props }: PressableCardProps) { + const scale = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + const handlePress = React.useCallback( (e: Parameters>[0]) => { if (haptic) void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); @@ -31,17 +49,36 @@ export function PressableCard({ [haptic, onPress] ); + const handlePressIn = React.useCallback( + (e: Parameters>[0]) => { + scale.value = withSpring(0.97, PRESS_SPRING); + onPressIn?.(e); + }, + [scale, onPressIn] + ); + + const handlePressOut = React.useCallback( + (e: Parameters>[0]) => { + scale.value = withSpring(1, PRESS_SPRING); + onPressOut?.(e); + }, + [scale, onPressOut] + ); + return ( - {children} - + ); }