Fade in animation for card (#3521)

* fade in and out the card

one more fix

dont leave an invisible card behind

okay just about there

move styles

glitch

clear hide timeouts on card enter

about there

* Tweak timings

* Rewrite with explicit states

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
zio/stable
Hailey 2024-04-13 10:28:53 -07:00 committed by GitHub
parent 1a9eeb760f
commit 228d947a84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 127 additions and 58 deletions

View File

@ -235,6 +235,17 @@
inset:0; inset:0;
animation: rotate 500ms linear infinite; animation: rotate 500ms linear infinite;
} }
@keyframes avatarHoverFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes avatarHoverFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style>
</style> </style>
{% include "scripts.html" %} {% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">

View File

@ -1,11 +1,11 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
@ -51,97 +51,144 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) {
return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} /> return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} />
} }
type State = 'hidden' | 'might-show' | 'showing' | 'might-hide' | 'hiding'
const SHOW_DELAY = 350
const SHOW_DURATION = 300
const HIDE_DELAY = 200
const HIDE_DURATION = 200
export function ProfileHoverCardInner(props: ProfileHoverCardProps) { export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
const [hovered, setHovered] = React.useState(false) const [state, setState] = React.useState<State>('hidden')
const {refs, floatingStyles} = useFloating({ const {refs, floatingStyles} = useFloating({
middleware: floatingMiddlewares, middleware: floatingMiddlewares,
}) })
const prefetchProfileQuery = usePrefetchProfileQuery() const animationStyle = {
animation:
state === 'hiding'
? `avatarHoverFadeOut ${HIDE_DURATION}ms both`
: `avatarHoverFadeIn ${SHOW_DURATION}ms both`,
}
const prefetchProfileQuery = usePrefetchProfileQuery()
const prefetchedProfile = React.useRef(false) const prefetchedProfile = React.useRef(false)
const targetHovered = React.useRef(false) const prefetchIfNeeded = React.useCallback(async () => {
const cardHovered = React.useRef(false) if (!prefetchedProfile.current) {
const targetClicked = React.useRef(false) prefetchProfileQuery(props.did)
const showTimeout = React.useRef<NodeJS.Timeout>() }
}, [prefetchProfileQuery, props.did])
const isVisible =
state === 'showing' || state === 'might-hide' || state === 'hiding'
// We need at most one timeout at a time (to transition to the next state).
const nextTimeout = React.useRef<NodeJS.Timeout | null>(null)
const transitionToState = React.useCallback((nextState: State) => {
if (nextTimeout.current) {
clearTimeout(nextTimeout.current)
nextTimeout.current = null
}
setState(nextState)
}, [])
const onReadyToShow = useNonReactiveCallback(() => {
if (state === 'might-show') {
transitionToState('showing')
}
})
const onReadyToHide = useNonReactiveCallback(() => {
if (state === 'might-hide') {
transitionToState('hiding')
nextTimeout.current = setTimeout(onHidingAnimationEnd, HIDE_DURATION)
}
})
const onHidingAnimationEnd = useNonReactiveCallback(() => {
if (state === 'hiding') {
transitionToState('hidden')
}
})
const onReceiveHover = useNonReactiveCallback(() => {
prefetchIfNeeded()
if (state === 'hidden') {
transitionToState('might-show')
nextTimeout.current = setTimeout(onReadyToShow, SHOW_DELAY)
} else if (state === 'might-show') {
// Do nothing
} else if (state === 'showing') {
// Do nothing
} else if (state === 'might-hide') {
transitionToState('showing')
} else if (state === 'hiding') {
transitionToState('showing')
}
})
const onLoseHover = useNonReactiveCallback(() => {
if (state === 'hidden') {
// Do nothing
} else if (state === 'might-show') {
transitionToState('hidden')
} else if (state === 'showing') {
transitionToState('might-hide')
nextTimeout.current = setTimeout(onReadyToHide, HIDE_DELAY)
} else if (state === 'might-hide') {
// Do nothing
} else if (state === 'hiding') {
// Do nothing
}
})
const onPointerEnterTarget = React.useCallback(() => { const onPointerEnterTarget = React.useCallback(() => {
showTimeout.current = setTimeout(async () => { onReceiveHover()
targetHovered.current = true }, [onReceiveHover])
if (prefetchedProfile.current) {
// if we're navigating
if (targetClicked.current) return
setHovered(true)
} else {
await prefetchProfileQuery(props.did)
if (targetHovered.current) {
setHovered(true)
}
prefetchedProfile.current = true
}
}, 350)
}, [props.did, prefetchProfileQuery])
const onPointerEnterCard = React.useCallback(() => {
cardHovered.current = true
// if we're navigating
if (targetClicked.current) return
setHovered(true)
}, [])
const onPointerLeaveTarget = React.useCallback(() => { const onPointerLeaveTarget = React.useCallback(() => {
clearTimeout(showTimeout.current) onLoseHover()
targetHovered.current = false }, [onLoseHover])
setTimeout(() => {
if (cardHovered.current) return const onPointerEnterCard = React.useCallback(() => {
setHovered(false) onReceiveHover()
}, 100) }, [onReceiveHover])
}, [])
const onPointerLeaveCard = React.useCallback(() => { const onPointerLeaveCard = React.useCallback(() => {
cardHovered.current = false onLoseHover()
setTimeout(() => { }, [onLoseHover])
if (targetHovered.current) return
setHovered(false) const onDismiss = React.useCallback(() => {
}, 100) transitionToState('hidden')
}, []) }, [transitionToState])
const onClickTarget = React.useCallback(() => {
targetClicked.current = true
setHovered(false)
}, [])
const hide = React.useCallback(() => {
setHovered(false)
}, [])
return ( return (
<div <div
ref={refs.setReference} ref={refs.setReference}
onPointerEnter={onPointerEnterTarget} onPointerEnter={onPointerEnterTarget}
onPointerLeave={onPointerLeaveTarget} onPointerLeave={onPointerLeaveTarget}
onMouseUp={onClickTarget} onMouseUp={onDismiss}
style={{ style={{
display: props.inline ? 'inline' : 'block', display: props.inline ? 'inline' : 'block',
}}> }}>
{props.children} {props.children}
{isVisible && (
{hovered && (
<Portal> <Portal>
<Animated.View <div style={animationStyle}>
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}>
<div <div
ref={refs.setFloating} ref={refs.setFloating}
style={floatingStyles} style={floatingStyles}
onPointerEnter={onPointerEnterCard} onPointerEnter={onPointerEnterCard}
onPointerLeave={onPointerLeaveCard}> onPointerLeave={onPointerLeaveCard}>
<Card did={props.did} hide={hide} /> <Card did={props.did} hide={onDismiss} />
</div>
</div> </div>
</Animated.View>
</Portal> </Portal>
)} )}
</div> </div>
) )
} }
function Card({did, hide}: {did: string; hide: () => void}) { let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
const t = useTheme() const t = useTheme()
const profile = useProfileQuery({did}) const profile = useProfileQuery({did})
@ -173,6 +220,7 @@ function Card({did, hide}: {did: string; hide: () => void}) {
</View> </View>
) )
} }
Card = React.memo(Card)
function Inner({ function Inner({
profile, profile,

View File

@ -239,6 +239,16 @@
inset:0; inset:0;
animation: rotate 500ms linear infinite; animation: rotate 500ms linear infinite;
} }
@keyframes avatarHoverFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes avatarHoverFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style> </style>
</head> </head>