Fix layout shift when liking a post on Android (#10190)

This commit is contained in:
Samuel Newman
2026-04-07 09:48:11 -07:00
committed by GitHub
parent 8bd6d9d135
commit a169bd862f
15 changed files with 76 additions and 245 deletions
+20 -46
View File
@@ -8,10 +8,7 @@ import Animated, {
} from 'react-native-reanimated'
import {decideShouldRoll} from '#/lib/custom-animations/util'
import {s} from '#/lib/styles'
import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import {atoms as a} from '#/alf'
const animationConfig = {
duration: 400,
@@ -87,89 +84,66 @@ function ExitingDown() {
}
export function CountWheel({
likeCount,
big,
isLiked,
count,
isToggled,
hasBeenToggled,
renderCount,
}: {
likeCount: number
big?: boolean
isLiked: boolean
count: number
isToggled: boolean
hasBeenToggled: boolean
renderCount: (props: {count: number}) => React.ReactNode
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldRoll = decideShouldRoll(isLiked, likeCount)
const shouldRoll = decideShouldRoll(isToggled, count)
// 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] = useState(0)
const [prevCount, setPrevCount] = useState(likeCount)
const prevIsLiked = useRef(isLiked)
const formatPostStatCount = useFormatPostStatCount()
const formattedCount = formatPostStatCount(likeCount)
const formattedPrevCount = formatPostStatCount(prevCount)
const [prevCount, setPrevCount] = useState(count)
const prevIsToggled = useRef(isToggled)
useEffect(() => {
if (isLiked === prevIsLiked.current) {
if (isToggled === prevIsToggled.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
const newPrevCount = isToggled ? count - 1 : count + 1
setKey(prev => prev + 1)
setPrevCount(newPrevCount)
prevIsLiked.current = isLiked
}, [isLiked, likeCount])
prevIsToggled.current = isToggled
}, [isToggled, count])
const enteringAnimation =
shouldAnimate && shouldRoll
? isLiked
? isToggled
? EnteringUp
: EnteringDown
: undefined
const exitingAnimation =
shouldAnimate && shouldRoll
? isLiked
? isToggled
? ExitingUp
: ExitingDown
: undefined
return (
<LayoutAnimationConfig skipEntering skipExiting>
{likeCount > 0 ? (
{count > 0 ? (
<View style={[a.justify_center]}>
<Animated.View entering={enteringAnimation} key={key}>
<Text
testID="likeCount"
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
{renderCount({count})}
</Animated.View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
{shouldAnimate && (count > 1 || !isToggled) ? (
<Animated.View
entering={exitingAnimation}
// Add 2 to the key so there are never duplicates
key={key + 2}
style={[a.absolute, {width: 50, opacity: 0}]}
aria-disabled={true}>
<Text
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
{renderCount({count: prevCount})}
</Animated.View>
) : null}
</View>
+19 -46
View File
@@ -3,10 +3,6 @@ import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {decideShouldRoll} from '#/lib/custom-animations/util'
import {s} from '#/lib/styles'
import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
import {useFormatPostStatCount} from '#/components/PostControls/util'
const animationConfig = {
duration: 400,
@@ -35,50 +31,46 @@ const exitingDownKeyframe = [
]
export function CountWheel({
likeCount,
big,
isLiked,
count,
isToggled,
hasBeenToggled,
renderCount,
}: {
likeCount: number
big?: boolean
isLiked: boolean
count: number
isToggled: boolean
hasBeenToggled: boolean
renderCount: (props: {count: number}) => React.ReactNode
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldRoll = decideShouldRoll(isLiked, likeCount)
const shouldRoll = decideShouldRoll(isToggled, count)
const countView = useRef<HTMLDivElement>(null)
const prevCountView = useRef<HTMLDivElement>(null)
const [prevCount, setPrevCount] = useState(likeCount)
const prevIsLiked = useRef(isLiked)
const formatPostStatCount = useFormatPostStatCount()
const formattedCount = formatPostStatCount(likeCount)
const formattedPrevCount = formatPostStatCount(prevCount)
const [prevCount, setPrevCount] = useState(count)
const prevIsToggled = useRef(isToggled)
useEffect(() => {
if (isLiked === prevIsLiked.current) {
if (isToggled === prevIsToggled.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
const newPrevCount = isToggled ? count - 1 : count + 1
if (shouldAnimate && shouldRoll) {
countView.current?.animate?.(
isLiked ? enteringUpKeyframe : enteringDownKeyframe,
isToggled ? enteringUpKeyframe : enteringDownKeyframe,
animationConfig,
)
prevCountView.current?.animate?.(
isLiked ? exitingUpKeyframe : exitingDownKeyframe,
isToggled ? exitingUpKeyframe : exitingDownKeyframe,
animationConfig,
)
setPrevCount(newPrevCount)
}
prevIsLiked.current = isLiked
}, [isLiked, likeCount, shouldAnimate, shouldRoll])
prevIsToggled.current = isToggled
}, [isToggled, count, shouldAnimate, shouldRoll])
if (likeCount < 1) {
if (count < 1) {
return null
}
@@ -87,34 +79,15 @@ export function CountWheel({
<View
// @ts-expect-error is div
ref={countView}>
<Text
testID="likeCount"
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
{renderCount({count})}
</View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
{shouldAnimate && (count > 1 || !isToggled) ? (
<View
style={{position: 'absolute', opacity: 0}}
aria-disabled={true}
// @ts-expect-error is div
ref={prevCountView}>
<Text
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
{renderCount({count: prevCount})}
</View>
) : null}
</View>
+2 -3
View File
@@ -5,7 +5,6 @@ import Animated, {
useReducedMotion,
} from 'react-native-reanimated'
import {s} from '#/lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
@@ -86,7 +85,7 @@ export function AnimatedLikeIcon({
{isLiked ? (
<Animated.View
entering={shouldAnimate ? keyframe.duration(300) : undefined}>
<HeartIconFilled style={s.likeColor} width={size} />
<HeartIconFilled style={{color: t.palette.pink}} width={size} />
</Animated.View>
) : (
<HeartIconOutline
@@ -100,7 +99,7 @@ export function AnimatedLikeIcon({
entering={circle1Keyframe.duration(300)}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
backgroundColor: t.palette.pink,
top: 0,
left: 0,
width: size,
+2 -3
View File
@@ -2,7 +2,6 @@ import {useEffect, useRef} 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,
@@ -74,7 +73,7 @@ export function AnimatedLikeIcon({
{isLiked ? (
// @ts-expect-error is div
<View ref={likeIconRef}>
<HeartIconFilled style={s.likeColor} width={size} />
<HeartIconFilled style={{color: t.palette.pink}} width={size} />
</View>
) : (
<HeartIconOutline
@@ -87,7 +86,7 @@ export function AnimatedLikeIcon({
ref={circle1Ref}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
backgroundColor: t.palette.pink,
top: 0,
left: 0,
width: size,
+1 -124
View File
@@ -1,9 +1,4 @@
import {
Dimensions,
type StyleProp,
StyleSheet,
type TextStyle,
} from 'react-native'
import {type StyleProp, StyleSheet, type TextStyle} from 'react-native'
import {IS_WEB} from '#/env'
import {type Theme, type TypographyVariant} from './ThemeContext'
@@ -61,14 +56,6 @@ export const colors = {
green5: '#082b03',
unreadNotifBg: '#ebf6ff',
brandBlue: '#0066FF',
like: '#ec4899',
}
export const gradients = {
blueLight: {start: '#5A71FA', end: colors.blue3}, // buttons
blue: {start: '#5E55FB', end: colors.blue3}, // fab
blueDark: {start: '#5F45E0', end: colors.blue3}, // avis, banner
}
/**
@@ -78,57 +65,6 @@ export const s = StyleSheet.create({
// helpers
footerSpacer: {height: 100},
contentContainer: {paddingBottom: 200},
contentContainerExtra: {paddingBottom: 300},
border0: {borderWidth: 0},
border1: {borderWidth: 1},
borderTop1: {borderTopWidth: 1},
borderRight1: {borderRightWidth: 1},
borderBottom1: {borderBottomWidth: 1},
borderLeft1: {borderLeftWidth: 1},
hidden: {display: 'none'},
dimmed: {opacity: 0.5},
// font weights
fw600: {fontWeight: '600'},
bold: {fontWeight: '600'},
fw500: {fontWeight: '600'},
semiBold: {fontWeight: '600'},
fw400: {fontWeight: '400'},
normal: {fontWeight: '400'},
fw300: {fontWeight: '400'},
light: {fontWeight: '400'},
// text decoration
underline: {textDecorationLine: 'underline'},
// font variants
tabularNum: {fontVariant: ['tabular-nums']},
// font sizes
f9: {fontSize: 9},
f10: {fontSize: 10},
f11: {fontSize: 11},
f12: {fontSize: 12},
f13: {fontSize: 13},
f14: {fontSize: 14},
f15: {fontSize: 15},
f16: {fontSize: 16},
f17: {fontSize: 17},
f18: {fontSize: 18},
// line heights
['lh13-1']: {lineHeight: 13},
['lh13-1.3']: {lineHeight: 16.9}, // 1.3 of 13px
['lh14-1']: {lineHeight: 14},
['lh14-1.3']: {lineHeight: 18.2}, // 1.3 of 14px
['lh15-1']: {lineHeight: 15},
['lh15-1.3']: {lineHeight: 19.5}, // 1.3 of 15px
['lh16-1']: {lineHeight: 16},
['lh16-1.3']: {lineHeight: 20.8}, // 1.3 of 16px
['lh17-1']: {lineHeight: 17},
['lh17-1.3']: {lineHeight: 22.1}, // 1.3 of 17px
['lh18-1']: {lineHeight: 18},
['lh18-1.3']: {lineHeight: 23.4}, // 1.3 of 18px
// margins
mr2: {marginRight: 2},
@@ -171,74 +107,15 @@ export const s = StyleSheet.create({
pb20: {paddingBottom: 20},
px5: {paddingHorizontal: 5},
// flex
flexRow: {flexDirection: 'row'},
flexCol: {flexDirection: 'column'},
flex1: {flex: 1},
flexGrow1: {flexGrow: 1},
alignCenter: {alignItems: 'center'},
alignBaseline: {alignItems: 'baseline'},
justifyCenter: {justifyContent: 'center'},
// position
absolute: {position: 'absolute'},
// dimensions
w100pct: {width: '100%'},
h100pct: {height: '100%'},
hContentRegion: IS_WEB ? {minHeight: '100%'} : {height: '100%'},
window: {
width: Dimensions.get('window').width,
height: Dimensions.get('window').height,
},
// text align
textLeft: {textAlign: 'left'},
textCenter: {textAlign: 'center'},
textRight: {textAlign: 'right'},
// colors
white: {color: colors.white},
black: {color: colors.black},
gray1: {color: colors.gray1},
gray2: {color: colors.gray2},
gray3: {color: colors.gray3},
gray4: {color: colors.gray4},
gray5: {color: colors.gray5},
blue1: {color: colors.blue1},
blue2: {color: colors.blue2},
blue3: {color: colors.blue3},
blue4: {color: colors.blue4},
blue5: {color: colors.blue5},
red1: {color: colors.red1},
red2: {color: colors.red2},
red3: {color: colors.red3},
red4: {color: colors.red4},
red5: {color: colors.red5},
pink1: {color: colors.pink1},
pink2: {color: colors.pink2},
pink3: {color: colors.pink3},
pink4: {color: colors.pink4},
pink5: {color: colors.pink5},
purple1: {color: colors.purple1},
purple2: {color: colors.purple2},
purple3: {color: colors.purple3},
purple4: {color: colors.purple4},
purple5: {color: colors.purple5},
green1: {color: colors.green1},
green2: {color: colors.green2},
green3: {color: colors.green3},
green4: {color: colors.green4},
green5: {color: colors.green5},
brandBlue: {color: colors.brandBlue},
likeColor: {color: colors.like},
})
export function lh(