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
+2 -3
View File
@@ -16,7 +16,6 @@ import * as Sentry from '@sentry/react-native'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import I18nProvider from '#/locale/i18nProvider'
@@ -58,7 +57,7 @@ import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {atoms as a, ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
@@ -175,7 +174,7 @@ function InnerApp() {
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
style={a.h_full}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
+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
@@ -136,6 +136,8 @@ export const BookmarkButton = memo(function BookmarkButton({
<PostControlButton
testID="postBookmarkBtn"
big={big}
active={isBookmarked}
activeColor={t.palette.primary_500}
label={
isBookmarked
? _(msg`Remove from saved posts`)
@@ -143,10 +145,7 @@ export const BookmarkButton = memo(function BookmarkButton({
}
onPress={onHandlePress}
hitSlop={hitSlop}>
<PostControlButtonIcon
fill={isBookmarked ? t.palette.primary_500 : undefined}
icon={isBookmarked ? BookmarkFilled : Bookmark}
/>
<PostControlButtonIcon icon={isBookmarked ? BookmarkFilled : Bookmark} />
</PostControlButton>
)
})
@@ -130,8 +130,11 @@ export function PostControlButtonText({style, ...props}: TextProps) {
<Text
style={[
color,
a.user_select_none,
big ? a.text_md : a.text_sm,
active && a.font_semi_bold,
// prevent layout shift on android
{includeFontPadding: false, textAlignVertical: 'center'},
style,
]}
{...props}
+11 -4
View File
@@ -24,7 +24,7 @@ import {
ProgressGuideAction,
useProgressGuideControls,
} 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 {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton'
@@ -74,6 +74,7 @@ let PostControls = ({
forceGoogleTranslate?: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const {openComposer} = useOpenComposer()
const {feedDescriptor} = useFeedFeedbackContext()
@@ -270,6 +271,8 @@ let PostControls = ({
<PostControlButton
testID="likeBtn"
big={big}
active={Boolean(post.viewer?.like)}
activeColor={t.palette.pink}
onPress={() => requireAuth(() => onPressToggleLike())}
label={
post.viewer?.like
@@ -296,10 +299,14 @@ let PostControls = ({
hasBeenToggled={hasLikeIconBeenToggled}
/>
<CountWheel
likeCount={post.likeCount ?? 0}
big={big}
isLiked={Boolean(post.viewer?.like)}
count={post.likeCount ?? 0}
isToggled={Boolean(post.viewer?.like)}
hasBeenToggled={hasLikeIconBeenToggled}
renderCount={({count}) => (
<PostControlButtonText>
{formatPostStatCount(count)}
</PostControlButtonText>
)}
/>
</PostControlButton>
</View>
+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(
+1 -1
View File
@@ -582,7 +582,7 @@ function LightboxFooter({
{altText ? (
<View accessibilityRole="button" style={styles.footerText}>
<Text
style={[s.gray3]}
style={{color: colors.gray3}}
numberOfLines={isAltExpanded ? undefined : 3}
selectable
onPress={() => {
+1 -1
View File
@@ -202,7 +202,7 @@ function ListItem({
<View style={styles.listItemContent}>
<Text
type="lg"
style={[s.bold, pal.text]}
style={[{fontWeight: '600'}, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(list.name)}
@@ -272,7 +272,7 @@ let NotificationFeedItem = ({
<HeartIconFilled
size="xl"
style={[
s.likeColor,
{color: t.palette.pink},
// {position: 'relative', top: -4}
]}
/>
+2 -2
View File
@@ -60,7 +60,7 @@ export function PostLoadingPlaceholder({
},
]}
/>
<View style={[s.flex1]}>
<View style={[a.flex_1]}>
<LoadingPlaceholder width={100} height={6} style={{marginBottom: 10}} />
<LoadingPlaceholder width="95%" height={6} style={{marginBottom: 8}} />
<LoadingPlaceholder width="95%" height={6} style={{marginBottom: 8}} />
@@ -238,7 +238,7 @@ export function FeedLoadingPlaceholder({
height={36}
style={[styles.avatar, {borderRadius: 8}]}
/>
<View style={[s.flex1]}>
<View style={[a.flex_1]}>
<LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} />
<LoadingPlaceholder width={120} height={8} />
</View>
+7 -7
View File
@@ -174,7 +174,7 @@ function NotifsView() {
}
return (
<View style={s.p10}>
<View style={s.flexRow}>
<View style={{flexDirection: 'row'}}>
<Button onPress={triggerPush} label="Trigger Push" />
<Button onPress={triggerToast} label="Trigger Toast" />
<Button onPress={triggerToast2} label="Trigger Toast 2" />
@@ -187,7 +187,7 @@ function PaletteView({palette}: {palette: PaletteColorName}) {
const defaultPal = usePalette('default')
const pal = usePalette(palette)
return (
<View style={[pal.view, pal.border, s.p10, s.mb5, s.border1]}>
<View style={[pal.view, pal.border, s.p10, s.mb5, {borderWidth: 1}]}>
<Text style={[pal.text]}>{palette} colors</Text>
<Text style={[pal.textLight]}>Light text</Text>
<Text style={[pal.link]}>Link text</Text>
@@ -343,15 +343,15 @@ function ButtonsView() {
const buttonStyles = {marginRight: 5}
return (
<View style={[defaultPal.view]}>
<View style={[s.flexRow, s.mb5]}>
<View style={[{flexDirection: 'row'}, s.mb5]}>
<Button type="primary" label="Primary solid" style={buttonStyles} />
<Button type="secondary" label="Secondary solid" style={buttonStyles} />
</View>
<View style={[s.flexRow, s.mb5]}>
<View style={[{flexDirection: 'row'}, s.mb5]}>
<Button type="default" label="Default solid" style={buttonStyles} />
<Button type="inverted" label="Inverted solid" style={buttonStyles} />
</View>
<View style={s.flexRow}>
<View style={{flexDirection: 'row'}}>
<Button
type="primary-outline"
label="Primary outline"
@@ -363,7 +363,7 @@ function ButtonsView() {
style={buttonStyles}
/>
</View>
<View style={s.flexRow}>
<View style={{flexDirection: 'row'}}>
<Button
type="primary-light"
label="Primary light"
@@ -375,7 +375,7 @@ function ButtonsView() {
style={buttonStyles}
/>
</View>
<View style={s.flexRow}>
<View style={{flexDirection: 'row'}}>
<Button
type="default-light"
label="Default light"