Improve animations for like button (#5074)

zio/stable
Hailey 2024-09-02 01:37:24 -07:00 committed by GitHub
parent eb868a042a
commit 1225e84485
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 580 additions and 247 deletions

View File

@ -0,0 +1,177 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {
Easing,
LayoutAnimationConfig,
useReducedMotion,
withTiming,
} from 'react-native-reanimated'
import {i18n} from '@lingui/core'
import {decideShouldRoll} from 'lib/custom-animations/util'
import {s} from 'lib/styles'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
const animationConfig = {
duration: 400,
easing: Easing.out(Easing.cubic),
}
function EnteringUp() {
'worklet'
const animations = {
opacity: withTiming(1, animationConfig),
transform: [{translateY: withTiming(0, animationConfig)}],
}
const initialValues = {
opacity: 0,
transform: [{translateY: 18}],
}
return {
animations,
initialValues,
}
}
function EnteringDown() {
'worklet'
const animations = {
opacity: withTiming(1, animationConfig),
transform: [{translateY: withTiming(0, animationConfig)}],
}
const initialValues = {
opacity: 0,
transform: [{translateY: -18}],
}
return {
animations,
initialValues,
}
}
function ExitingUp() {
'worklet'
const animations = {
opacity: withTiming(0, animationConfig),
transform: [
{
translateY: withTiming(-18, animationConfig),
},
],
}
const initialValues = {
opacity: 1,
transform: [{translateY: 0}],
}
return {
animations,
initialValues,
}
}
function ExitingDown() {
'worklet'
const animations = {
opacity: withTiming(0, animationConfig),
transform: [{translateY: withTiming(18, animationConfig)}],
}
const initialValues = {
opacity: 1,
transform: [{translateY: 0}],
}
return {
animations,
initialValues,
}
}
export function CountWheel({
likeCount,
big,
isLiked,
}: {
likeCount: number
big?: boolean
isLiked: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion()
const shouldRoll = decideShouldRoll(isLiked, likeCount)
// Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
// animation
// The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
// be unnecessary
const [key, setKey] = React.useState(0)
const [prevCount, setPrevCount] = React.useState(likeCount)
const prevIsLiked = React.useRef(isLiked)
const formattedCount = formatCount(i18n, likeCount)
const formattedPrevCount = formatCount(i18n, prevCount)
React.useEffect(() => {
if (isLiked === prevIsLiked.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
setKey(prev => prev + 1)
setPrevCount(newPrevCount)
prevIsLiked.current = isLiked
}, [isLiked, likeCount])
const enteringAnimation =
shouldAnimate && shouldRoll
? isLiked
? EnteringUp
: EnteringDown
: undefined
const exitingAnimation =
shouldAnimate && shouldRoll
? isLiked
? ExitingUp
: ExitingDown
: undefined
return (
<LayoutAnimationConfig skipEntering skipExiting>
{likeCount > 0 ? (
<View style={[a.justify_center]}>
<Animated.View entering={enteringAnimation} key={key}>
<Text
testID="likeCount"
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</Animated.View>
{shouldAnimate ? (
<Animated.View
entering={exitingAnimation}
// Add 2 to the key so there are never duplicates
key={key + 2}
style={[a.absolute, {width: 50}]}
aria-disabled={true}>
<Text
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</Animated.View>
) : null}
</View>
) : null}
</LayoutAnimationConfig>
)
}

View File

@ -0,0 +1,121 @@
import React from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {i18n} from '@lingui/core'
import {decideShouldRoll} from 'lib/custom-animations/util'
import {s} from 'lib/styles'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
const animationConfig = {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode,
}
const enteringUpKeyframe = [
{opacity: 0, transform: 'translateY(18px)'},
{opacity: 1, transform: 'translateY(0)'},
]
const enteringDownKeyframe = [
{opacity: 0, transform: 'translateY(-18px)'},
{opacity: 1, transform: 'translateY(0)'},
]
const exitingUpKeyframe = [
{opacity: 1, transform: 'translateY(0)'},
{opacity: 0, transform: 'translateY(-18px)'},
]
const exitingDownKeyframe = [
{opacity: 1, transform: 'translateY(0)'},
{opacity: 0, transform: 'translateY(18px)'},
]
export function CountWheel({
likeCount,
big,
isLiked,
}: {
likeCount: number
big?: boolean
isLiked: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion()
const shouldRoll = decideShouldRoll(isLiked, likeCount)
const countView = React.useRef<HTMLDivElement>(null)
const prevCountView = React.useRef<HTMLDivElement>(null)
const [prevCount, setPrevCount] = React.useState(likeCount)
const prevIsLiked = React.useRef(isLiked)
const formattedCount = formatCount(i18n, likeCount)
const formattedPrevCount = formatCount(i18n, prevCount)
React.useEffect(() => {
if (isLiked === prevIsLiked.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
if (shouldAnimate && shouldRoll) {
countView.current?.animate?.(
isLiked ? enteringUpKeyframe : enteringDownKeyframe,
animationConfig,
)
prevCountView.current?.animate?.(
isLiked ? exitingUpKeyframe : exitingDownKeyframe,
animationConfig,
)
setPrevCount(newPrevCount)
}
prevIsLiked.current = isLiked
}, [isLiked, likeCount, shouldAnimate, shouldRoll])
if (likeCount < 1) {
return null
}
return (
<View>
<View
aria-disabled={true}
// @ts-expect-error is div
ref={countView}>
<Text
testID="likeCount"
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</View>
{shouldAnimate ? (
<View
style={{position: 'absolute'}}
aria-disabled={true}
// @ts-expect-error is div
ref={prevCountView}>
<Text
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</View>
) : null}
</View>
)
}

View File

@ -0,0 +1,139 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {
Keyframe,
LayoutAnimationConfig,
useReducedMotion,
} from 'react-native-reanimated'
import {s} from 'lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
const keyframe = new Keyframe({
0: {
transform: [{scale: 1}],
},
10: {
transform: [{scale: 0.7}],
},
40: {
transform: [{scale: 1.2}],
},
100: {
transform: [{scale: 1}],
},
})
const circle1Keyframe = new Keyframe({
0: {
opacity: 0,
transform: [{scale: 0}],
},
10: {
opacity: 0.4,
},
40: {
transform: [{scale: 1.5}],
},
95: {
opacity: 0.4,
},
100: {
opacity: 0,
transform: [{scale: 1.5}],
},
})
const circle2Keyframe = new Keyframe({
0: {
opacity: 0,
transform: [{scale: 0}],
},
10: {
opacity: 1,
},
40: {
transform: [{scale: 0}],
},
95: {
opacity: 1,
},
100: {
opacity: 0,
transform: [{scale: 1.5}],
},
})
export function AnimatedLikeIcon({
isLiked,
big,
}: {
isLiked: boolean
big?: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion()
return (
<View>
<LayoutAnimationConfig skipEntering>
{isLiked ? (
<Animated.View
entering={shouldAnimate ? keyframe.duration(300) : undefined}>
<HeartIconFilled style={s.likeColor} width={size} />
</Animated.View>
) : (
<HeartIconOutline
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
width={size}
/>
)}
{isLiked ? (
<>
<Animated.View
entering={
shouldAnimate ? circle1Keyframe.duration(300) : undefined
}
style={[
{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
},
]}
/>
<Animated.View
entering={
shouldAnimate ? circle2Keyframe.duration(300) : undefined
}
style={[
{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
},
]}
/>
</>
) : null}
</LayoutAnimationConfig>
</View>
)
}

View File

@ -0,0 +1,115 @@
import React from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {s} from 'lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
const animationConfig = {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode,
}
const keyframe = [
{transform: 'scale(1)'},
{transform: 'scale(0.7)'},
{transform: 'scale(1.2)'},
{transform: 'scale(1)'},
]
const circle1Keyframe = [
{opacity: 0, transform: 'scale(0)'},
{opacity: 0.4},
{transform: 'scale(1.5)'},
{opacity: 0.4},
{opacity: 0, transform: 'scale(1.5)'},
]
const circle2Keyframe = [
{opacity: 0, transform: 'scale(0)'},
{opacity: 1},
{transform: 'scale(0)'},
{opacity: 1},
{opacity: 0, transform: 'scale(1.5)'},
]
export function AnimatedLikeIcon({
isLiked,
big,
}: {
isLiked: boolean
big?: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion()
const prevIsLiked = React.useRef(isLiked)
const likeIconRef = React.useRef<HTMLDivElement>(null)
const circle1Ref = React.useRef<HTMLDivElement>(null)
const circle2Ref = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (prevIsLiked.current === isLiked) {
return
}
if (shouldAnimate && isLiked) {
likeIconRef.current?.animate?.(keyframe, animationConfig)
circle1Ref.current?.animate?.(circle1Keyframe, animationConfig)
circle2Ref.current?.animate?.(circle2Keyframe, animationConfig)
}
prevIsLiked.current = isLiked
}, [shouldAnimate, isLiked])
return (
<View>
{isLiked ? (
// @ts-expect-error is div
<View ref={likeIconRef}>
<HeartIconFilled style={s.likeColor} width={size} />
</View>
) : (
<HeartIconOutline
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
width={size}
/>
)}
<View
// @ts-expect-error is div
ref={circle1Ref}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
}}
/>
<View
// @ts-expect-error is div
ref={circle2Ref}
style={{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
}}
/>
</View>
)
}

View File

@ -0,0 +1,21 @@
// It should roll when:
// - We're going from 1 to 0 (roll backwards)
// - The count is anywhere between 1 and 999
// - The count is going up and is a multiple of 100
// - The count is going down and is 1 less than a multiple of 100
export function decideShouldRoll(isSet: boolean, count: number) {
let shouldRoll = false
if (!isSet && count === 0) {
shouldRoll = true
} else if (count > 0 && count < 1000) {
shouldRoll = true
} else if (count > 0) {
const mod = count % 100
if (isSet && mod === 0) {
shouldRoll = true
} else if (!isSet && mod === 99) {
shouldRoll = true
}
}
return shouldRoll
}

View File

@ -6,14 +6,6 @@ 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,
@ -31,8 +23,6 @@ import {makeProfileLink} from '#/lib/routes/links'
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,16 +35,13 @@ import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {CountWheel} from 'lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from 'lib/custom-animations/LikeIcon'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
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'
@ -120,17 +107,7 @@ let PostCtrls = ({
) as StyleProp<ViewStyle>
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) {
@ -144,19 +121,6 @@ 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,
@ -167,15 +131,6 @@ let PostCtrls = ({
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) {
@ -185,8 +140,6 @@ let PostCtrls = ({
}
}, [
_,
likeIconAnimValue,
likeTextAnimValue,
playHaptic,
post.uri,
post.viewer?.like,
@ -291,8 +244,8 @@ let PostCtrls = ({
a.gap_xs,
a.rounded_full,
a.flex_row,
a.align_center,
a.justify_center,
a.align_center,
{padding: 5},
(pressed || hovered) && t.atoms.bg_contrast_25,
],
@ -364,13 +317,11 @@ let PostCtrls = ({
}
accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}>
<AnimatedLikeIcon
big={big ?? false}
likeIconAnimValue={likeIconAnimValue}
likeTextAnimValue={likeTextAnimValue}
defaultCtrlColor={defaultCtrlColor}
isLiked={Boolean(post.viewer?.like)}
<AnimatedLikeIcon isLiked={Boolean(post.viewer?.like)} big={big} />
<CountWheel
likeCount={post.likeCount ?? 0}
big={big}
isLiked={Boolean(post.viewer?.like)}
/>
</Pressable>
</View>
@ -450,194 +401,3 @@ let PostCtrls = ({
}
PostCtrls = memo(PostCtrls)
export {PostCtrls}
function AnimatedLikeIcon({
big,
likeIconAnimValue,
likeTextAnimValue,
defaultCtrlColor,
isLiked,
likeCount,
}: {
big: boolean
likeIconAnimValue: SharedValue<number>
likeTextAnimValue: SharedValue<number>
defaultCtrlColor: StyleProp<ViewStyle>
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 (
<>
<View>
<Animated.View
style={[
{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: big ? 22 : 18,
height: big ? 22 : 18,
zIndex: -1,
pointerEvents: 'none',
borderRadius: (big ? 22 : 18) / 2,
},
circle1Style,
]}
/>
<Animated.View
style={[
{
position: 'absolute',
backgroundColor: isWeb
? t.atoms.bg_contrast_25.backgroundColor
: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: big ? 22 : 18,
height: big ? 22 : 18,
zIndex: -1,
pointerEvents: 'none',
borderRadius: (big ? 22 : 18) / 2,
},
circle2Style,
]}
/>
<Animated.View style={likeStyle}>
{isLiked ? (
<HeartIconFilled style={s.likeColor} width={big ? 22 : 18} />
) : (
<HeartIconOutline
style={[defaultCtrlColor, {pointerEvents: 'none'}]}
width={big ? 22 : 18}
/>
)}
</Animated.View>
</View>
<View style={{overflow: 'hidden'}}>
<Text
testID="likeCount"
style={[
[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor,
{opacity: shouldRollLike ? 0 : 1},
],
]}>
{likeCount > 0 ? formatCount(i18n, likeCount) : ''}
</Text>
<Animated.View
aria-hidden={true}
style={[
countStyle,
{
position: 'absolute',
top: 0,
left: 0,
opacity: shouldRollLike ? 1 : 0,
},
]}>
<Text
testID="likeCount"
style={[
[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor,
{height: big ? 22 : 18},
],
]}>
{prevFormattedCount}
</Text>
<Text
testID="likeCount"
style={[
[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked ? [a.font_bold, s.likeColor] : defaultCtrlColor,
{height: big ? 22 : 18},
],
]}>
{nextFormattedCount}
</Text>
</Animated.View>
</View>
</>
)
}