Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

dan and committed by
GitHub
ed232e69 c41f372b

+248 -23
+248 -23
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' 9 17 import * as Clipboard from 'expo-clipboard' 10 18 import { 11 19 AppBskyFeedDefs, ··· 24 32 import {useGate} from '#/lib/statsig/statsig' 25 33 import {toShareUrl} from '#/lib/strings/url-helpers' 26 34 import {s} from '#/lib/styles' 35 + import {isWeb} from '#/platform/detection' 27 36 import {Shadow} from '#/state/cache/types' 28 37 import {useFeedFeedbackContext} from '#/state/feed-feedback' 29 38 import { ··· 45 54 Heart2_Stroke2_Corner0_Rounded as HeartIconOutline, 46 55 } from '#/components/icons/Heart2' 47 56 import * as Prompt from '#/components/Prompt' 57 + import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' 48 58 import {PostDropdownBtn} from '../forms/PostDropdownBtn' 49 59 import {formatCount} from '../numeric/format' 50 60 import {Text} from '../text/Text' ··· 109 119 [t], 110 120 ) as StyleProp<ViewStyle> 111 121 122 + const likeValue = post.viewer?.like ? 1 : 0 123 + const likeIconAnimValue = useSharedValue(likeValue) 124 + const likeTextAnimValue = useSharedValue(likeValue) 125 + 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 + 112 135 const onPressToggleLike = React.useCallback(async () => { 113 136 if (isBlocked) { 114 137 Toast.show( ··· 120 143 121 144 try { 122 145 if (!post.viewer?.like) { 146 + 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 + } 123 160 playHaptic() 124 161 sendInteraction({ 125 162 item: post.uri, ··· 129 166 captureAction(ProgressGuideAction.Like) 130 167 await queueLike() 131 168 } else { 169 + 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 + } 132 179 await queueUnlike() 133 180 } 134 181 } catch (e: any) { ··· 138 185 } 139 186 }, [ 140 187 _, 188 + likeIconAnimValue, 189 + likeTextAnimValue, 141 190 playHaptic, 142 191 post.uri, 143 192 post.viewer?.like, ··· 315 364 } 316 365 accessibilityHint="" 317 366 hitSlop={POST_CTRL_HITSLOP}> 318 - {post.viewer?.like ? ( 319 - <HeartIconFilled style={s.likeColor} width={big ? 22 : 18} /> 320 - ) : ( 321 - <HeartIconOutline 322 - style={[defaultCtrlColor, {pointerEvents: 'none'}]} 323 - width={big ? 22 : 18} 324 - /> 325 - )} 326 - {typeof post.likeCount !== 'undefined' && post.likeCount > 0 ? ( 327 - <Text 328 - testID="likeCount" 329 - style={[ 330 - [ 331 - big ? a.text_md : {fontSize: 15}, 332 - a.user_select_none, 333 - post.viewer?.like 334 - ? [a.font_bold, s.likeColor] 335 - : defaultCtrlColor, 336 - ], 337 - ]}> 338 - {formatCount(i18n, post.likeCount)} 339 - </Text> 340 - ) : undefined} 367 + <AnimatedLikeIcon 368 + big={big ?? false} 369 + likeIconAnimValue={likeIconAnimValue} 370 + likeTextAnimValue={likeTextAnimValue} 371 + defaultCtrlColor={defaultCtrlColor} 372 + isLiked={Boolean(post.viewer?.like)} 373 + likeCount={post.likeCount ?? 0} 374 + /> 341 375 </Pressable> 342 376 </View> 343 377 {big && ( ··· 416 450 } 417 451 PostCtrls = memo(PostCtrls) 418 452 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 + }