Improve animations for like button (#5074)
parent
eb868a042a
commit
1225e84485
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue