Integrate Sonner for toasts (#8839)

* Integrate Sonner for toasts

* Fix animation on iOS

* Refactor API

* Update e2e file
This commit is contained in:
Eric Bailey
2025-08-14 09:51:40 -05:00
committed by GitHub
parent 221623f55a
commit 7b2e61bf4d
12 changed files with 163 additions and 385 deletions
+40 -188
View File
@@ -1,197 +1,49 @@
import {useEffect, useMemo, useRef, useState} from 'react'
import {AccessibilityInfo} from 'react-native'
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler'
import Animated, {
Easing,
runOnJS,
SlideInUp,
SlideOutUp,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withDecay,
withSpring,
} from 'react-native-reanimated'
import RootSiblings from 'react-native-root-siblings'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {View} from 'react-native'
import {toast as sonner, Toaster} from 'sonner-native'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf'
import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const'
import {Toast} from '#/components/Toast/Toast'
import {type ToastApi, type ToastType} from '#/components/Toast/types'
import {DURATION} from '#/components/Toast/const'
import {
Toast as BaseToast,
type ToastComponentProps,
} from '#/components/Toast/Toast'
import {type BaseToastOptions} from '#/components/Toast/types'
const TOAST_ANIMATION_DURATION = 300
export {DURATION} from '#/components/Toast/const'
export function ToastContainer() {
return null
/**
* Toasts are rendered in a global outlet, which is placed at the top of the
* component tree.
*/
export function ToastOutlet() {
return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} />
}
export const toast: ToastApi = {
show(props) {
if (process.env.NODE_ENV === 'test') {
return
}
AccessibilityInfo.announceForAccessibility(props.a11yLabel)
const item = new RootSiblings(
(
<AnimatedToast
type={props.type}
content={props.content}
a11yLabel={props.a11yLabel}
duration={props.duration ?? DEFAULT_TOAST_DURATION}
destroy={() => item.destroy()}
/>
),
)
},
}
function AnimatedToast({
type,
content,
a11yLabel,
duration,
destroy,
}: {
type: ToastType
content: React.ReactNode
a11yLabel: string
duration: number
destroy: () => void
}) {
const {top} = useSafeAreaInsets()
const isPanning = useSharedValue(false)
const dismissSwipeTranslateY = useSharedValue(0)
const [cardHeight, setCardHeight] = useState(0)
// for the exit animation to work on iOS the animated component
// must not be the root component
// so we need to wrap it in a view and unmount the toast ahead of time
const [alive, setAlive] = useState(true)
const hideAndDestroyImmediately = () => {
setAlive(false)
setTimeout(() => {
destroy()
}, 1e3)
}
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => {
clearTimeout(destroyTimeoutRef.current)
destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, duration)
})
const pauseDestroy = useNonReactiveCallback(() => {
clearTimeout(destroyTimeoutRef.current)
})
useEffect(() => {
hideAndDestroyAfterTimeout()
}, [hideAndDestroyAfterTimeout])
const panGesture = useMemo(() => {
return Gesture.Pan()
.activeOffsetY([-10, 10])
.failOffsetX([-10, 10])
.maxPointers(1)
.onStart(() => {
'worklet'
if (!alive) return
isPanning.set(true)
runOnJS(pauseDestroy)()
})
.onUpdate(e => {
'worklet'
if (!alive) return
dismissSwipeTranslateY.value = e.translationY
})
.onEnd(e => {
'worklet'
if (!alive) return
runOnJS(hideAndDestroyAfterTimeout)()
isPanning.set(false)
if (e.velocityY < -100) {
if (dismissSwipeTranslateY.value === 0) {
// HACK: If the initial value is 0, withDecay() animation doesn't start.
// This is a bug in Reanimated, but for now we'll work around it like this.
dismissSwipeTranslateY.value = 1
}
dismissSwipeTranslateY.value = withDecay({
velocity: e.velocityY,
velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1),
deceleration: 1,
})
} else {
dismissSwipeTranslateY.value = withSpring(0, {
stiffness: 500,
damping: 50,
})
}
})
}, [
dismissSwipeTranslateY,
isPanning,
alive,
hideAndDestroyAfterTimeout,
pauseDestroy,
])
const topOffset = top + 10
useAnimatedReaction(
() =>
!isPanning.get() &&
dismissSwipeTranslateY.get() < -topOffset - cardHeight,
(isSwipedAway, prevIsSwipedAway) => {
'worklet'
if (isSwipedAway && !prevIsSwipedAway) {
runOnJS(destroy)()
}
},
)
const animatedStyle = useAnimatedStyle(() => {
const translation = dismissSwipeTranslateY.get()
return {
transform: [
{
translateY: translation > 0 ? translation ** 0.7 : translation,
},
],
}
})
/**
* The toast UI component
*/
export function Toast({type, content}: ToastComponentProps) {
return (
<GestureHandlerRootView
style={[a.absolute, {top: topOffset, left: 16, right: 16}]}
pointerEvents="box-none">
{alive && (
<Animated.View
entering={SlideInUp.easing(Easing.out(Easing.exp)).duration(
TOAST_ANIMATION_DURATION,
)}
exiting={SlideOutUp.easing(Easing.in(Easing.exp)).duration(
TOAST_ANIMATION_DURATION * 0.7,
)}
onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)}
accessibilityRole="alert"
accessible={true}
accessibilityLabel={a11yLabel}
accessibilityHint=""
onAccessibilityEscape={hideAndDestroyImmediately}
style={[a.flex_1, animatedStyle]}>
<GestureDetector gesture={panGesture}>
<Toast content={content} type={type} />
</GestureDetector>
</Animated.View>
)}
</GestureHandlerRootView>
<View style={[a.px_xl, a.w_full]}>
<BaseToast content={content} type={type} />
</View>
)
}
/**
* Access the full Sonner API
*/
export const api = sonner
/**
* Our base toast API, using the `Toast` export of this file.
*/
export function show(
content: React.ReactNode,
{type, ...options}: BaseToastOptions = {},
) {
sonner.custom(<Toast content={content} type={type} />, {
...options,
duration: options?.duration ?? DURATION,
})
}