Add web animations

This commit is contained in:
Eric Bailey
2025-08-13 10:48:20 -05:00
parent 4b720a3b86
commit e3f8fe6b69
3 changed files with 119 additions and 12 deletions
+106 -12
View File
@@ -5,6 +5,7 @@
import {useCallback, useEffect, useMemo, useState} from 'react'
import {AccessibilityInfo, Pressable, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Motion} from '@legendapp/motion'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure'
@@ -23,6 +24,9 @@ import {
export {useToast} from '#/components/Toast/Context'
export {Outlet} from '#/components/Toast/Portal'
const ANIMATION_DURATION = 300
const TOAST_GAP = 8 // Gap between toasts
const TOAST_ANIMATION_STYLES = {
entering: {
animation: 'toastFadeIn 0.3s ease-out forwards',
@@ -48,22 +52,54 @@ export function ToastProvider({children}: {children: React.ReactNode}) {
const [toasts, setToasts] = useState<
{
id: string
toast: ToastProps
timeout: NodeJS.Timeout
props: ToastProps
dimensions: {
width: number | undefined
height: number | undefined
}
setDimensions: (width: number, height: number) => void
exiting: boolean
timers: NodeJS.Timeout[]
}[]
>([])
const show = useCallback<ToastApi['show']>(props => {
setToasts(prevToasts => {
const id = nanoid()
const duration = props.duration || DEFAULT_TOAST_DURATION
return [
...prevToasts,
{
id,
toast: props,
timeout: setTimeout(() => {
setToasts(currentToasts => currentToasts.filter(t => t.id !== id))
}, props.duration || DEFAULT_TOAST_DURATION),
props,
dimensions: {width: undefined, height: undefined},
exiting: false,
setDimensions: (width: number, height: number) => {
setToasts(currentToasts =>
currentToasts.map(t => {
if (t.id === id) {
t.dimensions.width = width
t.dimensions.height = height
}
return t
}),
)
},
timers: [
setTimeout(() => {
setToasts(currentToasts =>
currentToasts.map(t => {
if (t.id === id) {
t.exiting = true
}
return t
}),
)
}, duration),
setTimeout(() => {
setToasts(currentToasts => currentToasts.filter(t => t.id !== id))
}, duration + ANIMATION_DURATION),
],
},
]
})
@@ -72,8 +108,8 @@ export function ToastProvider({children}: {children: React.ReactNode}) {
useEffect(() => {
return () => {
setToasts(toasts => {
toasts.map(({timeout}) => {
clearTimeout(timeout)
toasts.map(({timers}) => {
timers.map(clearTimeout)
})
return toasts
})
@@ -97,7 +133,6 @@ export function ToastProvider({children}: {children: React.ReactNode}) {
<View
style={[
a.fixed,
a.gap_sm,
{
flexDirection: 'column-reverse',
left: a.px_xl.paddingLeft,
@@ -110,9 +145,68 @@ export function ToastProvider({children}: {children: React.ReactNode}) {
},
],
]}>
{toasts.map(({id, toast}) => (
<Toast key={id} content={toast.content} type={toast.type} />
))}
{toasts.map(toast => {
const isReady = !!toast.dimensions.width
const toastComponent = (
<Toast
content={toast.props.content}
type={toast.props.type}
/>
)
if (!isReady) {
return (
<View
key={toast.id}
style={[a.absolute, a.inset_0, {opacity: 0}]}>
<View
style={[a.justify_end, {paddingTop: TOAST_GAP}]}
onLayout={e => {
if (toast.dimensions.width) return
toast.setDimensions(
e.nativeEvent.layout.width,
e.nativeEvent.layout.height,
)
}}>
{toastComponent}
</View>
</View>
)
}
return (
<Motion.View
key={toast.id}
initial={{opacity: 0, y: 20}}
animate={{
y: 0,
opacity: toast.exiting ? 0 : 1,
height: toast.exiting ? 0 : toast.dimensions.height,
}}
transition={{
type: 'timing',
easing: 'circOut',
duration: ANIMATION_DURATION,
}}>
<View
key={toast.id}
style={[
a.justify_end,
{
position: toast.exiting ? 'absolute' : 'relative',
width: toast.dimensions.width,
height: toast.dimensions.height,
paddingTop: TOAST_GAP,
},
]}>
<Toast
content={toast.props.content}
type={toast.props.type}
/>
</View>
</Motion.View>
)
})}
</View>
</Portal.Portal>
)}