-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Improve animations for like button (#5074)
- Loading branch information
Showing
6 changed files
with
580 additions
and
247 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
) | ||
} |
Oops, something went wrong.