Bluesky app fork with some witchin' additions 💫

Improve animations for like button (#5074)

authored by hailey.at and committed by

GitHub 1225e844 eb868a04

+580 -247
+177
src/lib/custom-animations/CountWheel.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import Animated, { 4 + Easing, 5 + LayoutAnimationConfig, 6 + useReducedMotion, 7 + withTiming, 8 + } from 'react-native-reanimated' 9 + import {i18n} from '@lingui/core' 10 + 11 + import {decideShouldRoll} from 'lib/custom-animations/util' 12 + import {s} from 'lib/styles' 13 + import {formatCount} from 'view/com/util/numeric/format' 14 + import {Text} from 'view/com/util/text/Text' 15 + import {atoms as a, useTheme} from '#/alf' 16 + 17 + const animationConfig = { 18 + duration: 400, 19 + easing: Easing.out(Easing.cubic), 20 + } 21 + 22 + function EnteringUp() { 23 + 'worklet' 24 + const animations = { 25 + opacity: withTiming(1, animationConfig), 26 + transform: [{translateY: withTiming(0, animationConfig)}], 27 + } 28 + const initialValues = { 29 + opacity: 0, 30 + transform: [{translateY: 18}], 31 + } 32 + return { 33 + animations, 34 + initialValues, 35 + } 36 + } 37 + 38 + function EnteringDown() { 39 + 'worklet' 40 + const animations = { 41 + opacity: withTiming(1, animationConfig), 42 + transform: [{translateY: withTiming(0, animationConfig)}], 43 + } 44 + const initialValues = { 45 + opacity: 0, 46 + transform: [{translateY: -18}], 47 + } 48 + return { 49 + animations, 50 + initialValues, 51 + } 52 + } 53 + 54 + function ExitingUp() { 55 + 'worklet' 56 + const animations = { 57 + opacity: withTiming(0, animationConfig), 58 + transform: [ 59 + { 60 + translateY: withTiming(-18, animationConfig), 61 + }, 62 + ], 63 + } 64 + const initialValues = { 65 + opacity: 1, 66 + transform: [{translateY: 0}], 67 + } 68 + return { 69 + animations, 70 + initialValues, 71 + } 72 + } 73 + 74 + function ExitingDown() { 75 + 'worklet' 76 + const animations = { 77 + opacity: withTiming(0, animationConfig), 78 + transform: [{translateY: withTiming(18, animationConfig)}], 79 + } 80 + const initialValues = { 81 + opacity: 1, 82 + transform: [{translateY: 0}], 83 + } 84 + return { 85 + animations, 86 + initialValues, 87 + } 88 + } 89 + 90 + export function CountWheel({ 91 + likeCount, 92 + big, 93 + isLiked, 94 + }: { 95 + likeCount: number 96 + big?: boolean 97 + isLiked: boolean 98 + }) { 99 + const t = useTheme() 100 + const shouldAnimate = !useReducedMotion() 101 + const shouldRoll = decideShouldRoll(isLiked, likeCount) 102 + 103 + // Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting 104 + // animation 105 + // The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would 106 + // be unnecessary 107 + const [key, setKey] = React.useState(0) 108 + const [prevCount, setPrevCount] = React.useState(likeCount) 109 + const prevIsLiked = React.useRef(isLiked) 110 + const formattedCount = formatCount(i18n, likeCount) 111 + const formattedPrevCount = formatCount(i18n, prevCount) 112 + 113 + React.useEffect(() => { 114 + if (isLiked === prevIsLiked.current) { 115 + return 116 + } 117 + 118 + const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1 119 + setKey(prev => prev + 1) 120 + setPrevCount(newPrevCount) 121 + prevIsLiked.current = isLiked 122 + }, [isLiked, likeCount]) 123 + 124 + const enteringAnimation = 125 + shouldAnimate && shouldRoll 126 + ? isLiked 127 + ? EnteringUp 128 + : EnteringDown 129 + : undefined 130 + const exitingAnimation = 131 + shouldAnimate && shouldRoll 132 + ? isLiked 133 + ? ExitingUp 134 + : ExitingDown 135 + : undefined 136 + 137 + return ( 138 + <LayoutAnimationConfig skipEntering skipExiting> 139 + {likeCount > 0 ? ( 140 + <View style={[a.justify_center]}> 141 + <Animated.View entering={enteringAnimation} key={key}> 142 + <Text 143 + testID="likeCount" 144 + style={[ 145 + big ? a.text_md : {fontSize: 15}, 146 + a.user_select_none, 147 + isLiked 148 + ? [a.font_bold, s.likeColor] 149 + : {color: t.palette.contrast_500}, 150 + ]}> 151 + {formattedCount} 152 + </Text> 153 + </Animated.View> 154 + {shouldAnimate ? ( 155 + <Animated.View 156 + entering={exitingAnimation} 157 + // Add 2 to the key so there are never duplicates 158 + key={key + 2} 159 + style={[a.absolute, {width: 50}]} 160 + aria-disabled={true}> 161 + <Text 162 + style={[ 163 + big ? a.text_md : {fontSize: 15}, 164 + a.user_select_none, 165 + isLiked 166 + ? [a.font_bold, s.likeColor] 167 + : {color: t.palette.contrast_500}, 168 + ]}> 169 + {formattedPrevCount} 170 + </Text> 171 + </Animated.View> 172 + ) : null} 173 + </View> 174 + ) : null} 175 + </LayoutAnimationConfig> 176 + ) 177 + }
+121
src/lib/custom-animations/CountWheel.web.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useReducedMotion} from 'react-native-reanimated' 4 + import {i18n} from '@lingui/core' 5 + 6 + import {decideShouldRoll} from 'lib/custom-animations/util' 7 + import {s} from 'lib/styles' 8 + import {formatCount} from 'view/com/util/numeric/format' 9 + import {Text} from 'view/com/util/text/Text' 10 + import {atoms as a, useTheme} from '#/alf' 11 + 12 + const animationConfig = { 13 + duration: 400, 14 + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', 15 + fill: 'forwards' as FillMode, 16 + } 17 + 18 + const enteringUpKeyframe = [ 19 + {opacity: 0, transform: 'translateY(18px)'}, 20 + {opacity: 1, transform: 'translateY(0)'}, 21 + ] 22 + 23 + const enteringDownKeyframe = [ 24 + {opacity: 0, transform: 'translateY(-18px)'}, 25 + {opacity: 1, transform: 'translateY(0)'}, 26 + ] 27 + 28 + const exitingUpKeyframe = [ 29 + {opacity: 1, transform: 'translateY(0)'}, 30 + {opacity: 0, transform: 'translateY(-18px)'}, 31 + ] 32 + 33 + const exitingDownKeyframe = [ 34 + {opacity: 1, transform: 'translateY(0)'}, 35 + {opacity: 0, transform: 'translateY(18px)'}, 36 + ] 37 + 38 + export function CountWheel({ 39 + likeCount, 40 + big, 41 + isLiked, 42 + }: { 43 + likeCount: number 44 + big?: boolean 45 + isLiked: boolean 46 + }) { 47 + const t = useTheme() 48 + const shouldAnimate = !useReducedMotion() 49 + const shouldRoll = decideShouldRoll(isLiked, likeCount) 50 + 51 + const countView = React.useRef<HTMLDivElement>(null) 52 + const prevCountView = React.useRef<HTMLDivElement>(null) 53 + 54 + const [prevCount, setPrevCount] = React.useState(likeCount) 55 + const prevIsLiked = React.useRef(isLiked) 56 + const formattedCount = formatCount(i18n, likeCount) 57 + const formattedPrevCount = formatCount(i18n, prevCount) 58 + 59 + React.useEffect(() => { 60 + if (isLiked === prevIsLiked.current) { 61 + return 62 + } 63 + 64 + const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1 65 + if (shouldAnimate && shouldRoll) { 66 + countView.current?.animate?.( 67 + isLiked ? enteringUpKeyframe : enteringDownKeyframe, 68 + animationConfig, 69 + ) 70 + prevCountView.current?.animate?.( 71 + isLiked ? exitingUpKeyframe : exitingDownKeyframe, 72 + animationConfig, 73 + ) 74 + setPrevCount(newPrevCount) 75 + } 76 + prevIsLiked.current = isLiked 77 + }, [isLiked, likeCount, shouldAnimate, shouldRoll]) 78 + 79 + if (likeCount < 1) { 80 + return null 81 + } 82 + 83 + return ( 84 + <View> 85 + <View 86 + aria-disabled={true} 87 + // @ts-expect-error is div 88 + ref={countView}> 89 + <Text 90 + testID="likeCount" 91 + style={[ 92 + big ? a.text_md : {fontSize: 15}, 93 + a.user_select_none, 94 + isLiked 95 + ? [a.font_bold, s.likeColor] 96 + : {color: t.palette.contrast_500}, 97 + ]}> 98 + {formattedCount} 99 + </Text> 100 + </View> 101 + {shouldAnimate ? ( 102 + <View 103 + style={{position: 'absolute'}} 104 + aria-disabled={true} 105 + // @ts-expect-error is div 106 + ref={prevCountView}> 107 + <Text 108 + style={[ 109 + big ? a.text_md : {fontSize: 15}, 110 + a.user_select_none, 111 + isLiked 112 + ? [a.font_bold, s.likeColor] 113 + : {color: t.palette.contrast_500}, 114 + ]}> 115 + {formattedPrevCount} 116 + </Text> 117 + </View> 118 + ) : null} 119 + </View> 120 + ) 121 + }
+139
src/lib/custom-animations/LikeIcon.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import Animated, { 4 + Keyframe, 5 + LayoutAnimationConfig, 6 + useReducedMotion, 7 + } from 'react-native-reanimated' 8 + 9 + import {s} from 'lib/styles' 10 + import {useTheme} from '#/alf' 11 + import { 12 + Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled, 13 + Heart2_Stroke2_Corner0_Rounded as HeartIconOutline, 14 + } from '#/components/icons/Heart2' 15 + 16 + const keyframe = new Keyframe({ 17 + 0: { 18 + transform: [{scale: 1}], 19 + }, 20 + 10: { 21 + transform: [{scale: 0.7}], 22 + }, 23 + 40: { 24 + transform: [{scale: 1.2}], 25 + }, 26 + 100: { 27 + transform: [{scale: 1}], 28 + }, 29 + }) 30 + 31 + const circle1Keyframe = new Keyframe({ 32 + 0: { 33 + opacity: 0, 34 + transform: [{scale: 0}], 35 + }, 36 + 10: { 37 + opacity: 0.4, 38 + }, 39 + 40: { 40 + transform: [{scale: 1.5}], 41 + }, 42 + 95: { 43 + opacity: 0.4, 44 + }, 45 + 100: { 46 + opacity: 0, 47 + transform: [{scale: 1.5}], 48 + }, 49 + }) 50 + 51 + const circle2Keyframe = new Keyframe({ 52 + 0: { 53 + opacity: 0, 54 + transform: [{scale: 0}], 55 + }, 56 + 10: { 57 + opacity: 1, 58 + }, 59 + 40: { 60 + transform: [{scale: 0}], 61 + }, 62 + 95: { 63 + opacity: 1, 64 + }, 65 + 100: { 66 + opacity: 0, 67 + transform: [{scale: 1.5}], 68 + }, 69 + }) 70 + 71 + export function AnimatedLikeIcon({ 72 + isLiked, 73 + big, 74 + }: { 75 + isLiked: boolean 76 + big?: boolean 77 + }) { 78 + const t = useTheme() 79 + const size = big ? 22 : 18 80 + const shouldAnimate = !useReducedMotion() 81 + 82 + return ( 83 + <View> 84 + <LayoutAnimationConfig skipEntering> 85 + {isLiked ? ( 86 + <Animated.View 87 + entering={shouldAnimate ? keyframe.duration(300) : undefined}> 88 + <HeartIconFilled style={s.likeColor} width={size} /> 89 + </Animated.View> 90 + ) : ( 91 + <HeartIconOutline 92 + style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]} 93 + width={size} 94 + /> 95 + )} 96 + {isLiked ? ( 97 + <> 98 + <Animated.View 99 + entering={ 100 + shouldAnimate ? circle1Keyframe.duration(300) : undefined 101 + } 102 + style={[ 103 + { 104 + position: 'absolute', 105 + backgroundColor: s.likeColor.color, 106 + top: 0, 107 + left: 0, 108 + width: size, 109 + height: size, 110 + zIndex: -1, 111 + pointerEvents: 'none', 112 + borderRadius: size / 2, 113 + }, 114 + ]} 115 + /> 116 + <Animated.View 117 + entering={ 118 + shouldAnimate ? circle2Keyframe.duration(300) : undefined 119 + } 120 + style={[ 121 + { 122 + position: 'absolute', 123 + backgroundColor: t.atoms.bg.backgroundColor, 124 + top: 0, 125 + left: 0, 126 + width: size, 127 + height: size, 128 + zIndex: -1, 129 + pointerEvents: 'none', 130 + borderRadius: size / 2, 131 + }, 132 + ]} 133 + /> 134 + </> 135 + ) : null} 136 + </LayoutAnimationConfig> 137 + </View> 138 + ) 139 + }
+115
src/lib/custom-animations/LikeIcon.web.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useReducedMotion} from 'react-native-reanimated' 4 + 5 + import {s} from 'lib/styles' 6 + import {useTheme} from '#/alf' 7 + import { 8 + Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled, 9 + Heart2_Stroke2_Corner0_Rounded as HeartIconOutline, 10 + } from '#/components/icons/Heart2' 11 + 12 + const animationConfig = { 13 + duration: 400, 14 + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', 15 + fill: 'forwards' as FillMode, 16 + } 17 + 18 + const keyframe = [ 19 + {transform: 'scale(1)'}, 20 + {transform: 'scale(0.7)'}, 21 + {transform: 'scale(1.2)'}, 22 + {transform: 'scale(1)'}, 23 + ] 24 + 25 + const circle1Keyframe = [ 26 + {opacity: 0, transform: 'scale(0)'}, 27 + {opacity: 0.4}, 28 + {transform: 'scale(1.5)'}, 29 + {opacity: 0.4}, 30 + {opacity: 0, transform: 'scale(1.5)'}, 31 + ] 32 + 33 + const circle2Keyframe = [ 34 + {opacity: 0, transform: 'scale(0)'}, 35 + {opacity: 1}, 36 + {transform: 'scale(0)'}, 37 + {opacity: 1}, 38 + {opacity: 0, transform: 'scale(1.5)'}, 39 + ] 40 + 41 + export function AnimatedLikeIcon({ 42 + isLiked, 43 + big, 44 + }: { 45 + isLiked: boolean 46 + big?: boolean 47 + }) { 48 + const t = useTheme() 49 + const size = big ? 22 : 18 50 + const shouldAnimate = !useReducedMotion() 51 + const prevIsLiked = React.useRef(isLiked) 52 + 53 + const likeIconRef = React.useRef<HTMLDivElement>(null) 54 + const circle1Ref = React.useRef<HTMLDivElement>(null) 55 + const circle2Ref = React.useRef<HTMLDivElement>(null) 56 + 57 + React.useEffect(() => { 58 + if (prevIsLiked.current === isLiked) { 59 + return 60 + } 61 + 62 + if (shouldAnimate && isLiked) { 63 + likeIconRef.current?.animate?.(keyframe, animationConfig) 64 + circle1Ref.current?.animate?.(circle1Keyframe, animationConfig) 65 + circle2Ref.current?.animate?.(circle2Keyframe, animationConfig) 66 + } 67 + prevIsLiked.current = isLiked 68 + }, [shouldAnimate, isLiked]) 69 + 70 + return ( 71 + <View> 72 + {isLiked ? ( 73 + // @ts-expect-error is div 74 + <View ref={likeIconRef}> 75 + <HeartIconFilled style={s.likeColor} width={size} /> 76 + </View> 77 + ) : ( 78 + <HeartIconOutline 79 + style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]} 80 + width={size} 81 + /> 82 + )} 83 + <View 84 + // @ts-expect-error is div 85 + ref={circle1Ref} 86 + style={{ 87 + position: 'absolute', 88 + backgroundColor: s.likeColor.color, 89 + top: 0, 90 + left: 0, 91 + width: size, 92 + height: size, 93 + zIndex: -1, 94 + pointerEvents: 'none', 95 + borderRadius: size / 2, 96 + }} 97 + /> 98 + <View 99 + // @ts-expect-error is div 100 + ref={circle2Ref} 101 + style={{ 102 + position: 'absolute', 103 + backgroundColor: t.atoms.bg.backgroundColor, 104 + top: 0, 105 + left: 0, 106 + width: size, 107 + height: size, 108 + zIndex: -1, 109 + pointerEvents: 'none', 110 + borderRadius: size / 2, 111 + }} 112 + /> 113 + </View> 114 + ) 115 + }
+21
src/lib/custom-animations/util.ts
··· 1 + // It should roll when: 2 + // - We're going from 1 to 0 (roll backwards) 3 + // - The count is anywhere between 1 and 999 4 + // - The count is going up and is a multiple of 100 5 + // - The count is going down and is 1 less than a multiple of 100 6 + export function decideShouldRoll(isSet: boolean, count: number) { 7 + let shouldRoll = false 8 + if (!isSet && count === 0) { 9 + shouldRoll = true 10 + } else if (count > 0 && count < 1000) { 11 + shouldRoll = true 12 + } else if (count > 0) { 13 + const mod = count % 100 14 + if (isSet && mod === 0) { 15 + shouldRoll = true 16 + } else if (!isSet && mod === 99) { 17 + shouldRoll = true 18 + } 19 + } 20 + return shouldRoll 21 + }
+7 -247
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 6 6 View, 7 7 type ViewStyle, 8 8 } from 'react-native' 9 - import Animated, { 10 - Easing, 11 - interpolate, 12 - SharedValue, 13 - useAnimatedStyle, 14 - useSharedValue, 15 - withTiming, 16 - } from 'react-native-reanimated' 17 9 import * as Clipboard from 'expo-clipboard' 18 10 import { 19 11 AppBskyFeedDefs, ··· 31 23 import {shareUrl} from '#/lib/sharing' 32 24 import {useGate} from '#/lib/statsig/statsig' 33 25 import {toShareUrl} from '#/lib/strings/url-helpers' 34 - import {s} from '#/lib/styles' 35 - import {isWeb} from '#/platform/detection' 36 26 import {Shadow} from '#/state/cache/types' 37 27 import {useFeedFeedbackContext} from '#/state/feed-feedback' 38 28 import { ··· 45 35 ProgressGuideAction, 46 36 useProgressGuideControls, 47 37 } from '#/state/shell/progress-guide' 38 + import {CountWheel} from 'lib/custom-animations/CountWheel' 39 + import {AnimatedLikeIcon} from 'lib/custom-animations/LikeIcon' 48 40 import {atoms as a, useTheme} from '#/alf' 49 41 import {useDialogControl} from '#/components/Dialog' 50 42 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' 51 43 import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' 52 - import { 53 - Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled, 54 - Heart2_Stroke2_Corner0_Rounded as HeartIconOutline, 55 - } from '#/components/icons/Heart2' 56 44 import * as Prompt from '#/components/Prompt' 57 - import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' 58 45 import {PostDropdownBtn} from '../forms/PostDropdownBtn' 59 46 import {formatCount} from '../numeric/format' 60 47 import {Text} from '../text/Text' ··· 120 107 ) as StyleProp<ViewStyle> 121 108 122 109 const likeValue = post.viewer?.like ? 1 : 0 123 - const likeIconAnimValue = useSharedValue(likeValue) 124 - const likeTextAnimValue = useSharedValue(likeValue) 125 110 const nextExpectedLikeValue = React.useRef(likeValue) 126 - React.useEffect(() => { 127 - // Catch nonlocal changes (e.g. shadow update) and always reflect them. 128 - if (likeValue !== nextExpectedLikeValue.current) { 129 - nextExpectedLikeValue.current = likeValue 130 - likeIconAnimValue.value = likeValue 131 - likeTextAnimValue.value = likeValue 132 - } 133 - }, [likeValue, likeIconAnimValue, likeTextAnimValue]) 134 111 135 112 const onPressToggleLike = React.useCallback(async () => { 136 113 if (isBlocked) { ··· 144 121 try { 145 122 if (!post.viewer?.like) { 146 123 nextExpectedLikeValue.current = 1 147 - if (PlatformInfo.getIsReducedMotionEnabled()) { 148 - likeIconAnimValue.value = 1 149 - likeTextAnimValue.value = 1 150 - } else { 151 - likeIconAnimValue.value = withTiming(1, { 152 - duration: 400, 153 - easing: Easing.out(Easing.cubic), 154 - }) 155 - likeTextAnimValue.value = withTiming(1, { 156 - duration: 400, 157 - easing: Easing.out(Easing.cubic), 158 - }) 159 - } 160 124 playHaptic() 161 125 sendInteraction({ 162 126 item: post.uri, ··· 167 131 await queueLike() 168 132 } else { 169 133 nextExpectedLikeValue.current = 0 170 - likeIconAnimValue.value = 0 // Intentionally not animated 171 - if (PlatformInfo.getIsReducedMotionEnabled()) { 172 - likeTextAnimValue.value = 0 173 - } else { 174 - likeTextAnimValue.value = withTiming(0, { 175 - duration: 400, 176 - easing: Easing.out(Easing.cubic), 177 - }) 178 - } 179 134 await queueUnlike() 180 135 } 181 136 } catch (e: any) { ··· 185 140 } 186 141 }, [ 187 142 _, 188 - likeIconAnimValue, 189 - likeTextAnimValue, 190 143 playHaptic, 191 144 post.uri, 192 145 post.viewer?.like, ··· 291 244 a.gap_xs, 292 245 a.rounded_full, 293 246 a.flex_row, 294 - a.align_center, 295 247 a.justify_center, 248 + a.align_center, 296 249 {padding: 5}, 297 250 (pressed || hovered) && t.atoms.bg_contrast_25, 298 251 ], ··· 364 317 } 365 318 accessibilityHint="" 366 319 hitSlop={POST_CTRL_HITSLOP}> 367 - <AnimatedLikeIcon 368 - big={big ?? false} 369 - likeIconAnimValue={likeIconAnimValue} 370 - likeTextAnimValue={likeTextAnimValue} 371 - defaultCtrlColor={defaultCtrlColor} 320 + <AnimatedLikeIcon isLiked={Boolean(post.viewer?.like)} big={big} /> 321 + <CountWheel 322 + likeCount={post.likeCount ?? 0} 323 + big={big} 372 324 isLiked={Boolean(post.viewer?.like)} 373 - likeCount={post.likeCount ?? 0} 374 325 /> 375 326 </Pressable> 376 327 </View> ··· 450 401 } 451 402 PostCtrls = memo(PostCtrls) 452 403 export {PostCtrls} 453 - 454 - function AnimatedLikeIcon({ 455 - big, 456 - likeIconAnimValue, 457 - likeTextAnimValue, 458 - defaultCtrlColor, 459 - isLiked, 460 - likeCount, 461 - }: { 462 - big: boolean 463 - likeIconAnimValue: SharedValue<number> 464 - likeTextAnimValue: SharedValue<number> 465 - defaultCtrlColor: StyleProp<ViewStyle> 466 - isLiked: boolean 467 - likeCount: number 468 - }) { 469 - const t = useTheme() 470 - const {i18n} = useLingui() 471 - const likeStyle = useAnimatedStyle(() => ({ 472 - transform: [ 473 - { 474 - scale: interpolate( 475 - likeIconAnimValue.value, 476 - [0, 0.1, 0.4, 1], 477 - [1, 0.7, 1.2, 1], 478 - 'clamp', 479 - ), 480 - }, 481 - ], 482 - })) 483 - const circle1Style = useAnimatedStyle(() => ({ 484 - opacity: interpolate( 485 - likeIconAnimValue.value, 486 - [0, 0.1, 0.95, 1], 487 - [0, 0.4, 0.4, 0], 488 - 'clamp', 489 - ), 490 - transform: [ 491 - { 492 - scale: interpolate( 493 - likeIconAnimValue.value, 494 - [0, 0.4, 1], 495 - [0, 1.5, 1.5], 496 - 'clamp', 497 - ), 498 - }, 499 - ], 500 - })) 501 - const circle2Style = useAnimatedStyle(() => ({ 502 - opacity: interpolate( 503 - likeIconAnimValue.value, 504 - [0, 0.1, 0.95, 1], 505 - [0, 1, 1, 0], 506 - 'clamp', 507 - ), 508 - transform: [ 509 - { 510 - scale: interpolate( 511 - likeIconAnimValue.value, 512 - [0, 0.4, 1], 513 - [0, 0, 1.5], 514 - 'clamp', 515 - ), 516 - }, 517 - ], 518 - })) 519 - const countStyle = useAnimatedStyle(() => ({ 520 - transform: [ 521 - { 522 - translateY: interpolate( 523 - likeTextAnimValue.value, 524 - [0, 1], 525 - [0, big ? -22 : -18], 526 - 'clamp', 527 - ), 528 - }, 529 - ], 530 - })) 531 - 532 - const prevFormattedCount = formatCount( 533 - i18n, 534 - isLiked ? likeCount - 1 : likeCount, 535 - ) 536 - const nextFormattedCount = formatCount( 537 - i18n, 538 - isLiked ? likeCount : likeCount + 1, 539 - ) 540 - const shouldRollLike = 541 - prevFormattedCount !== nextFormattedCount && prevFormattedCount !== '0' 542 - 543 - return ( 544 - <> 545 - <View> 546 - <Animated.View 547 - style={[ 548 - { 549 - position: 'absolute', 550 - backgroundColor: s.likeColor.color, 551 - top: 0, 552 - left: 0, 553 - width: big ? 22 : 18, 554 - height: big ? 22 : 18, 555 - zIndex: -1, 556 - pointerEvents: 'none', 557 - borderRadius: (big ? 22 : 18) / 2, 558 - }, 559 - circle1Style, 560 - ]} 561 - /> 562 - <Animated.View 563 - style={[ 564 - { 565 - position: 'absolute', 566 - backgroundColor: isWeb 567 - ? t.atoms.bg_contrast_25.backgroundColor 568 - : t.atoms.bg.backgroundColor, 569 - top: 0, 570 - left: 0, 571 - width: big ? 22 : 18, 572 - height: big ? 22 : 18, 573 - zIndex: -1, 574 - pointerEvents: 'none', 575 - borderRadius: (big ? 22 : 18) / 2, 576 - }, 577 - circle2Style, 578 - ]} 579 - /> 580 - <Animated.View style={likeStyle}> 581 - {isLiked ? ( 582 - <HeartIconFilled style={s.likeColor} width={big ? 22 : 18} /> 583 - ) : ( 584 - <HeartIconOutline 585 - style={[defaultCtrlColor, {pointerEvents: 'none'}]} 586 - width={big ? 22 : 18} 587 - /> 588 - )} 589 - </Animated.View> 590 - </View> 591 - <View style={{overflow: 'hidden'}}> 592 - <Text 593 - testID="likeCount" 594 - style={[ 595 - [ 596 - big ? a.text_md : {fontSize: 15}, 597 - a.user_select_none, 598 - isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor, 599 - {opacity: shouldRollLike ? 0 : 1}, 600 - ], 601 - ]}> 602 - {likeCount > 0 ? formatCount(i18n, likeCount) : ''} 603 - </Text> 604 - <Animated.View 605 - aria-hidden={true} 606 - style={[ 607 - countStyle, 608 - { 609 - position: 'absolute', 610 - top: 0, 611 - left: 0, 612 - opacity: shouldRollLike ? 1 : 0, 613 - }, 614 - ]}> 615 - <Text 616 - testID="likeCount" 617 - style={[ 618 - [ 619 - big ? a.text_md : {fontSize: 15}, 620 - a.user_select_none, 621 - isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor, 622 - {height: big ? 22 : 18}, 623 - ], 624 - ]}> 625 - {prevFormattedCount} 626 - </Text> 627 - <Text 628 - testID="likeCount" 629 - style={[ 630 - [ 631 - big ? a.text_md : {fontSize: 15}, 632 - a.user_select_none, 633 - isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor, 634 - {height: big ? 22 : 18}, 635 - ], 636 - ]}> 637 - {nextFormattedCount} 638 - </Text> 639 - </Animated.View> 640 - </View> 641 - </> 642 - ) 643 - }