Fix layout shift when liking a post on Android, make countwheel generic

This commit is contained in:
Samuel Newman
2026-04-07 10:52:29 +03:00
parent 5868804d3b
commit 07845189ca
5 changed files with 54 additions and 96 deletions
+1
View File
@@ -110,6 +110,7 @@ const Context = createContext<AnalyticsBaseContextType>({
}, },
}, },
}) })
Context.displayName = 'AnalyticsContext'
/** /**
* Ensures that deviceId is set and migrated from legacy storage. Handled on * Ensures that deviceId is set and migrated from legacy storage. Handled on
@@ -130,8 +130,11 @@ export function PostControlButtonText({style, ...props}: TextProps) {
<Text <Text
style={[ style={[
color, color,
a.user_select_none,
big ? a.text_md : a.text_sm, big ? a.text_md : a.text_sm,
active && a.font_semi_bold, active && a.font_semi_bold,
// prevent layout shift on android
{includeFontPadding: false, textAlignVertical: 'center'},
style, style,
]} ]}
{...props} {...props}
+11 -4
View File
@@ -24,7 +24,7 @@ import {
ProgressGuideAction, ProgressGuideAction,
useProgressGuideControls, useProgressGuideControls,
} from '#/state/shell/progress-guide' } from '#/state/shell/progress-guide'
import {atoms as a, useBreakpoints} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Reply as Bubble} from '#/components/icons/Reply' import {Reply as Bubble} from '#/components/icons/Reply'
import {useFormatPostStatCount} from '#/components/PostControls/util' import {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton' import * as Skele from '#/components/Skeleton'
@@ -74,6 +74,7 @@ let PostControls = ({
forceGoogleTranslate?: boolean forceGoogleTranslate?: boolean
}): React.ReactNode => { }): React.ReactNode => {
const ax = useAnalytics() const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const {feedDescriptor} = useFeedFeedbackContext() const {feedDescriptor} = useFeedFeedbackContext()
@@ -270,6 +271,8 @@ let PostControls = ({
<PostControlButton <PostControlButton
testID="likeBtn" testID="likeBtn"
big={big} big={big}
active={Boolean(post.viewer?.like)}
activeColor={t.palette.pink}
onPress={() => requireAuth(() => onPressToggleLike())} onPress={() => requireAuth(() => onPressToggleLike())}
label={ label={
post.viewer?.like post.viewer?.like
@@ -296,10 +299,14 @@ let PostControls = ({
hasBeenToggled={hasLikeIconBeenToggled} hasBeenToggled={hasLikeIconBeenToggled}
/> />
<CountWheel <CountWheel
likeCount={post.likeCount ?? 0} count={post.likeCount ?? 0}
big={big} isToggled={Boolean(post.viewer?.like)}
isLiked={Boolean(post.viewer?.like)}
hasBeenToggled={hasLikeIconBeenToggled} hasBeenToggled={hasLikeIconBeenToggled}
renderCount={({count}) => (
<PostControlButtonText>
{formatPostStatCount(count)}
</PostControlButtonText>
)}
/> />
</PostControlButton> </PostControlButton>
</View> </View>
+20 -46
View File
@@ -8,10 +8,7 @@ import Animated, {
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {decideShouldRoll} from '#/lib/custom-animations/util' import {decideShouldRoll} from '#/lib/custom-animations/util'
import {s} from '#/lib/styles' import {atoms as a} from '#/alf'
import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
import {useFormatPostStatCount} from '#/components/PostControls/util'
const animationConfig = { const animationConfig = {
duration: 400, duration: 400,
@@ -87,89 +84,66 @@ function ExitingDown() {
} }
export function CountWheel({ export function CountWheel({
likeCount, count,
big, isToggled,
isLiked,
hasBeenToggled, hasBeenToggled,
renderCount,
}: { }: {
likeCount: number count: number
big?: boolean isToggled: boolean
isLiked: boolean
hasBeenToggled: boolean hasBeenToggled: boolean
renderCount: (props: {count: number}) => React.ReactNode
}) { }) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && hasBeenToggled 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 // Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
// animation // animation
// The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would // The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
// be unnecessary // be unnecessary
const [key, setKey] = useState(0) const [key, setKey] = useState(0)
const [prevCount, setPrevCount] = useState(likeCount) const [prevCount, setPrevCount] = useState(count)
const prevIsLiked = useRef(isLiked) const prevIsToggled = useRef(isToggled)
const formatPostStatCount = useFormatPostStatCount()
const formattedCount = formatPostStatCount(likeCount)
const formattedPrevCount = formatPostStatCount(prevCount)
useEffect(() => { useEffect(() => {
if (isLiked === prevIsLiked.current) { if (isToggled === prevIsToggled.current) {
return return
} }
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1 const newPrevCount = isToggled ? count - 1 : count + 1
setKey(prev => prev + 1) setKey(prev => prev + 1)
setPrevCount(newPrevCount) setPrevCount(newPrevCount)
prevIsLiked.current = isLiked prevIsToggled.current = isToggled
}, [isLiked, likeCount]) }, [isToggled, count])
const enteringAnimation = const enteringAnimation =
shouldAnimate && shouldRoll shouldAnimate && shouldRoll
? isLiked ? isToggled
? EnteringUp ? EnteringUp
: EnteringDown : EnteringDown
: undefined : undefined
const exitingAnimation = const exitingAnimation =
shouldAnimate && shouldRoll shouldAnimate && shouldRoll
? isLiked ? isToggled
? ExitingUp ? ExitingUp
: ExitingDown : ExitingDown
: undefined : undefined
return ( return (
<LayoutAnimationConfig skipEntering skipExiting> <LayoutAnimationConfig skipEntering skipExiting>
{likeCount > 0 ? ( {count > 0 ? (
<View style={[a.justify_center]}> <View style={[a.justify_center]}>
<Animated.View entering={enteringAnimation} key={key}> <Animated.View entering={enteringAnimation} key={key}>
<Text {renderCount({count})}
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>
</Animated.View> </Animated.View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? ( {shouldAnimate && (count > 1 || !isToggled) ? (
<Animated.View <Animated.View
entering={exitingAnimation} entering={exitingAnimation}
// Add 2 to the key so there are never duplicates // Add 2 to the key so there are never duplicates
key={key + 2} key={key + 2}
style={[a.absolute, {width: 50, opacity: 0}]} style={[a.absolute, {width: 50, opacity: 0}]}
aria-disabled={true}> aria-disabled={true}>
<Text {renderCount({count: prevCount})}
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>
</Animated.View> </Animated.View>
) : null} ) : null}
</View> </View>
+19 -46
View File
@@ -3,10 +3,6 @@ import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated' import {useReducedMotion} from 'react-native-reanimated'
import {decideShouldRoll} from '#/lib/custom-animations/util' 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 = { const animationConfig = {
duration: 400, duration: 400,
@@ -35,50 +31,46 @@ const exitingDownKeyframe = [
] ]
export function CountWheel({ export function CountWheel({
likeCount, count,
big, isToggled,
isLiked,
hasBeenToggled, hasBeenToggled,
renderCount,
}: { }: {
likeCount: number count: number
big?: boolean isToggled: boolean
isLiked: boolean
hasBeenToggled: boolean hasBeenToggled: boolean
renderCount: (props: {count: number}) => React.ReactNode
}) { }) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && hasBeenToggled const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldRoll = decideShouldRoll(isLiked, likeCount) const shouldRoll = decideShouldRoll(isToggled, count)
const countView = useRef<HTMLDivElement>(null) const countView = useRef<HTMLDivElement>(null)
const prevCountView = useRef<HTMLDivElement>(null) const prevCountView = useRef<HTMLDivElement>(null)
const [prevCount, setPrevCount] = useState(likeCount) const [prevCount, setPrevCount] = useState(count)
const prevIsLiked = useRef(isLiked) const prevIsToggled = useRef(isToggled)
const formatPostStatCount = useFormatPostStatCount()
const formattedCount = formatPostStatCount(likeCount)
const formattedPrevCount = formatPostStatCount(prevCount)
useEffect(() => { useEffect(() => {
if (isLiked === prevIsLiked.current) { if (isToggled === prevIsToggled.current) {
return return
} }
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1 const newPrevCount = isToggled ? count - 1 : count + 1
if (shouldAnimate && shouldRoll) { if (shouldAnimate && shouldRoll) {
countView.current?.animate?.( countView.current?.animate?.(
isLiked ? enteringUpKeyframe : enteringDownKeyframe, isToggled ? enteringUpKeyframe : enteringDownKeyframe,
animationConfig, animationConfig,
) )
prevCountView.current?.animate?.( prevCountView.current?.animate?.(
isLiked ? exitingUpKeyframe : exitingDownKeyframe, isToggled ? exitingUpKeyframe : exitingDownKeyframe,
animationConfig, animationConfig,
) )
setPrevCount(newPrevCount) setPrevCount(newPrevCount)
} }
prevIsLiked.current = isLiked prevIsToggled.current = isToggled
}, [isLiked, likeCount, shouldAnimate, shouldRoll]) }, [isToggled, count, shouldAnimate, shouldRoll])
if (likeCount < 1) { if (count < 1) {
return null return null
} }
@@ -87,34 +79,15 @@ export function CountWheel({
<View <View
// @ts-expect-error is div // @ts-expect-error is div
ref={countView}> ref={countView}>
<Text {renderCount({count})}
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>
</View> </View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? ( {shouldAnimate && (count > 1 || !isToggled) ? (
<View <View
style={{position: 'absolute', opacity: 0}} style={{position: 'absolute', opacity: 0}}
aria-disabled={true} aria-disabled={true}
// @ts-expect-error is div // @ts-expect-error is div
ref={prevCountView}> ref={prevCountView}>
<Text {renderCount({count: prevCount})}
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>
</View> </View>
) : null} ) : null}
</View> </View>