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,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import Animated, {
|
|
||||||
Easing,
|
|
||||||
interpolate,
|
|
||||||
SharedValue,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from 'react-native-reanimated'
|
|
||||||
import * as Clipboard from 'expo-clipboard'
|
import * as Clipboard from 'expo-clipboard'
|
||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
|
@ -31,8 +23,6 @@ import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {shareUrl} from '#/lib/sharing'
|
import {shareUrl} from '#/lib/sharing'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||||
import {s} from '#/lib/styles'
|
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {Shadow} from '#/state/cache/types'
|
||||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {
|
import {
|
||||||
|
@ -45,16 +35,13 @@ import {
|
||||||
ProgressGuideAction,
|
ProgressGuideAction,
|
||||||
useProgressGuideControls,
|
useProgressGuideControls,
|
||||||
} from '#/state/shell/progress-guide'
|
} 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 {atoms as a, useTheme} from '#/alf'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
|
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
|
||||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
|
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 * as Prompt from '#/components/Prompt'
|
||||||
import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
|
|
||||||
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
|
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
|
||||||
import {formatCount} from '../numeric/format'
|
import {formatCount} from '../numeric/format'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
|
@ -120,17 +107,7 @@ let PostCtrls = ({
|
||||||
) as StyleProp<ViewStyle>
|
) as StyleProp<ViewStyle>
|
||||||
|
|
||||||
const likeValue = post.viewer?.like ? 1 : 0
|
const likeValue = post.viewer?.like ? 1 : 0
|
||||||
const likeIconAnimValue = useSharedValue(likeValue)
|
|
||||||
const likeTextAnimValue = useSharedValue(likeValue)
|
|
||||||
const nextExpectedLikeValue = React.useRef(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 () => {
|
const onPressToggleLike = React.useCallback(async () => {
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
|
@ -144,19 +121,6 @@ let PostCtrls = ({
|
||||||
try {
|
try {
|
||||||
if (!post.viewer?.like) {
|
if (!post.viewer?.like) {
|
||||||
nextExpectedLikeValue.current = 1
|
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()
|
playHaptic()
|
||||||
sendInteraction({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
|
@ -167,15 +131,6 @@ let PostCtrls = ({
|
||||||
await queueLike()
|
await queueLike()
|
||||||
} else {
|
} else {
|
||||||
nextExpectedLikeValue.current = 0
|
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()
|
await queueUnlike()
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
@ -185,8 +140,6 @@ let PostCtrls = ({
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
_,
|
_,
|
||||||
likeIconAnimValue,
|
|
||||||
likeTextAnimValue,
|
|
||||||
playHaptic,
|
playHaptic,
|
||||||
post.uri,
|
post.uri,
|
||||||
post.viewer?.like,
|
post.viewer?.like,
|
||||||
|
@ -291,8 +244,8 @@ let PostCtrls = ({
|
||||||
a.gap_xs,
|
a.gap_xs,
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
{padding: 5},
|
{padding: 5},
|
||||||
(pressed || hovered) && t.atoms.bg_contrast_25,
|
(pressed || hovered) && t.atoms.bg_contrast_25,
|
||||||
],
|
],
|
||||||
|
@ -364,13 +317,11 @@ let PostCtrls = ({
|
||||||
}
|
}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
hitSlop={POST_CTRL_HITSLOP}>
|
hitSlop={POST_CTRL_HITSLOP}>
|
||||||
<AnimatedLikeIcon
|
<AnimatedLikeIcon isLiked={Boolean(post.viewer?.like)} big={big} />
|
||||||
big={big ?? false}
|
<CountWheel
|
||||||
likeIconAnimValue={likeIconAnimValue}
|
|
||||||
likeTextAnimValue={likeTextAnimValue}
|
|
||||||
defaultCtrlColor={defaultCtrlColor}
|
|
||||||
isLiked={Boolean(post.viewer?.like)}
|
|
||||||
likeCount={post.likeCount ?? 0}
|
likeCount={post.likeCount ?? 0}
|
||||||
|
big={big}
|
||||||
|
isLiked={Boolean(post.viewer?.like)}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
@ -450,194 +401,3 @@ let PostCtrls = ({
|
||||||
}
|
}
|
||||||
PostCtrls = memo(PostCtrls)
|
PostCtrls = memo(PostCtrls)
|
||||||
export {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