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
+1
View File
@@ -88,6 +88,7 @@
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0",
"@legendapp/motion": "^2.4.0",
"@lingui/react": "^4.14.1",
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
"@miblanchard/react-native-slider": "^2.6.0",
+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>
)}
+12
View File
@@ -5013,6 +5013,18 @@
resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c"
integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==
"@legendapp/motion@^2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@legendapp/motion/-/motion-2.4.0.tgz#eef6f934b784e07fe94631d5648d412f34fa0c6b"
integrity sha512-AAYpRLGvxGD5hIGl9sVHyoUufr66zoH82PuxYcKiPSMdCBI3jwZFWh6CuHjV1leRKVIRk2py1rSvIVabG8eqcw==
dependencies:
"@legendapp/tools" "2.0.1"
"@legendapp/tools@2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@legendapp/tools/-/tools-2.0.1.tgz#995fe6cb3e2398b939f645505aa8e1abc84bd07f"
integrity sha512-Kxt0HWvWElRK6oybHRzcYxdgaKGwuaiRNreS7usW7QuHXRIHaH4yMcW2YNRG4DHE5fpefv+Bno/BohQcCE4FaA==
"@leichtgewicht/ip-codec@^2.0.1":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b"