From ed232e69f7178eed536788d97ee039ebccb2397d Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 30 Aug 2024 23:16:11 +0100 Subject: [PATCH] Animate the like button (#5033) * Animate the like button * Respect reduced motion * Move like count into animated component * Animate text * Fix layout on Android * Animate text backwards too * Fix bad copypasta * Reflect nonlocal updates to animated values --- src/view/com/util/post-ctrls/PostCtrls.tsx | 271 +++++++++++++++++++-- 1 file changed, 248 insertions(+), 23 deletions(-) diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 05a14ed7..49c9229a 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -6,6 +6,14 @@ import { View, type ViewStyle, } from 'react-native' +import Animated, { + Easing, + interpolate, + SharedValue, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' import * as Clipboard from 'expo-clipboard' import { AppBskyFeedDefs, @@ -24,6 +32,7 @@ import {shareUrl} from '#/lib/sharing' import {useGate} from '#/lib/statsig/statsig' import {toShareUrl} from '#/lib/strings/url-helpers' import {s} from '#/lib/styles' +import {isWeb} from '#/platform/detection' import {Shadow} from '#/state/cache/types' import {useFeedFeedbackContext} from '#/state/feed-feedback' import { @@ -45,6 +54,7 @@ import { Heart2_Stroke2_Corner0_Rounded as HeartIconOutline, } from '#/components/icons/Heart2' import * as Prompt from '#/components/Prompt' +import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {formatCount} from '../numeric/format' import {Text} from '../text/Text' @@ -109,6 +119,19 @@ let PostCtrls = ({ [t], ) as StyleProp + const likeValue = post.viewer?.like ? 1 : 0 + const likeIconAnimValue = useSharedValue(likeValue) + const likeTextAnimValue = useSharedValue(likeValue) + const nextExpectedLikeValue = React.useRef(likeValue) + React.useEffect(() => { + // Catch nonlocal changes (e.g. shadow update) and always reflect them. + if (likeValue !== nextExpectedLikeValue.current) { + nextExpectedLikeValue.current = likeValue + likeIconAnimValue.value = likeValue + likeTextAnimValue.value = likeValue + } + }, [likeValue, likeIconAnimValue, likeTextAnimValue]) + const onPressToggleLike = React.useCallback(async () => { if (isBlocked) { Toast.show( @@ -120,6 +143,20 @@ let PostCtrls = ({ try { if (!post.viewer?.like) { + nextExpectedLikeValue.current = 1 + if (PlatformInfo.getIsReducedMotionEnabled()) { + likeIconAnimValue.value = 1 + likeTextAnimValue.value = 1 + } else { + likeIconAnimValue.value = withTiming(1, { + duration: 400, + easing: Easing.out(Easing.cubic), + }) + likeTextAnimValue.value = withTiming(1, { + duration: 400, + easing: Easing.out(Easing.cubic), + }) + } playHaptic() sendInteraction({ item: post.uri, @@ -129,6 +166,16 @@ let PostCtrls = ({ captureAction(ProgressGuideAction.Like) await queueLike() } else { + nextExpectedLikeValue.current = 0 + likeIconAnimValue.value = 0 // Intentionally not animated + if (PlatformInfo.getIsReducedMotionEnabled()) { + likeTextAnimValue.value = 0 + } else { + likeTextAnimValue.value = withTiming(0, { + duration: 400, + easing: Easing.out(Easing.cubic), + }) + } await queueUnlike() } } catch (e: any) { @@ -138,6 +185,8 @@ let PostCtrls = ({ } }, [ _, + likeIconAnimValue, + likeTextAnimValue, playHaptic, post.uri, post.viewer?.like, @@ -315,29 +364,14 @@ let PostCtrls = ({ } accessibilityHint="" hitSlop={POST_CTRL_HITSLOP}> - {post.viewer?.like ? ( - - ) : ( - - )} - {typeof post.likeCount !== 'undefined' && post.likeCount > 0 ? ( - - {formatCount(i18n, post.likeCount)} - - ) : undefined} + {big && ( @@ -416,3 +450,194 @@ let PostCtrls = ({ } PostCtrls = memo(PostCtrls) export {PostCtrls} + +function AnimatedLikeIcon({ + big, + likeIconAnimValue, + likeTextAnimValue, + defaultCtrlColor, + isLiked, + likeCount, +}: { + big: boolean + likeIconAnimValue: SharedValue + likeTextAnimValue: SharedValue + defaultCtrlColor: StyleProp + isLiked: boolean + likeCount: number +}) { + const t = useTheme() + const {i18n} = useLingui() + const likeStyle = useAnimatedStyle(() => ({ + transform: [ + { + scale: interpolate( + likeIconAnimValue.value, + [0, 0.1, 0.4, 1], + [1, 0.7, 1.2, 1], + 'clamp', + ), + }, + ], + })) + const circle1Style = useAnimatedStyle(() => ({ + opacity: interpolate( + likeIconAnimValue.value, + [0, 0.1, 0.95, 1], + [0, 0.4, 0.4, 0], + 'clamp', + ), + transform: [ + { + scale: interpolate( + likeIconAnimValue.value, + [0, 0.4, 1], + [0, 1.5, 1.5], + 'clamp', + ), + }, + ], + })) + const circle2Style = useAnimatedStyle(() => ({ + opacity: interpolate( + likeIconAnimValue.value, + [0, 0.1, 0.95, 1], + [0, 1, 1, 0], + 'clamp', + ), + transform: [ + { + scale: interpolate( + likeIconAnimValue.value, + [0, 0.4, 1], + [0, 0, 1.5], + 'clamp', + ), + }, + ], + })) + const countStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: interpolate( + likeTextAnimValue.value, + [0, 1], + [0, big ? -22 : -18], + 'clamp', + ), + }, + ], + })) + + const prevFormattedCount = formatCount( + i18n, + isLiked ? likeCount - 1 : likeCount, + ) + const nextFormattedCount = formatCount( + i18n, + isLiked ? likeCount : likeCount + 1, + ) + const shouldRollLike = + prevFormattedCount !== nextFormattedCount && prevFormattedCount !== '0' + + return ( + <> + + + + + {isLiked ? ( + + ) : ( + + )} + + + + + {likeCount > 0 ? formatCount(i18n, likeCount) : ''} + + + + {prevFormattedCount} + + + {nextFormattedCount} + + + + + ) +}