Improve animations for like button (#5074)

This commit is contained in:
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
}