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
parent
1a9eeb760f
commit
228d947a84
|
@ -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">
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue