From 76ca72cf727e926101ec60eb232f0797e6584b49 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 22 Nov 2024 16:11:59 +0000 Subject: Dismissable toasts (#6345) * dismissable toast * adjust top offset * improve a11y * stretchy pull-down * Dismiss web on tap * Simplify code --------- Co-authored-by: Dan Abramov --- src/view/com/util/Toast.tsx | 203 +++++++++++++++++++++++++++++++--------- src/view/com/util/Toast.web.tsx | 17 +++- 2 files changed, 175 insertions(+), 45 deletions(-) (limited to 'src') diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 105afe13d..b57e676ae 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,6 +1,20 @@ -import {useEffect, useState} from 'react' -import {View} from 'react-native' -import Animated, {FadeInUp, FadeOutUp} from 'react-native-reanimated' +import {useEffect, useMemo, useRef, useState} from 'react' +import {AccessibilityInfo, View} from 'react-native' +import { + Gesture, + GestureDetector, + GestureHandlerRootView, +} from 'react-native-gesture-handler' +import Animated, { + FadeInUp, + FadeOutUp, + runOnJS, + 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 { @@ -8,6 +22,7 @@ import { Props as FontAwesomeProps, } from '@fortawesome/react-native-fontawesome' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' import {IS_TEST} from '#/env' @@ -19,74 +34,174 @@ export function show( icon: FontAwesomeProps['icon'] = 'check', ) { if (IS_TEST) return - const item = new RootSiblings() - // timeout has some leeway to account for the animation - setTimeout(() => { - item.destroy() - }, TIMEOUT + 1e3) + AccessibilityInfo.announceForAccessibility(message) + const item = new RootSiblings( + item.destroy()} />, + ) } function Toast({ message, icon, + destroy, }: { message: string icon: FontAwesomeProps['icon'] + destroy: () => void }) { const t = useTheme() 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) - useEffect(() => { + const hideAndDestroyImmediately = () => { + setAlive(false) setTimeout(() => { - setAlive(false) - }, TIMEOUT) - }, []) + destroy() + }, 1e3) + } + + const destroyTimeoutRef = useRef>() + const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { + clearTimeout(destroyTimeoutRef.current) + destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT) + }) + 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, + }, + ], + } + }) return ( - + {alive && ( - + setCardHeight(evt.nativeEvent.layout.height)} + accessibilityRole="alert" + accessible={true} + accessibilityLabel={message} + accessibilityHint="" + onAccessibilityEscape={hideAndDestroyImmediately} style={[ - a.flex_shrink_0, - a.rounded_full, - {width: 32, height: 32}, - t.atoms.bg_contrast_25, - a.align_center, - a.justify_center, + a.flex_1, + t.atoms.bg, + a.shadow_lg, + t.atoms.border_contrast_medium, + a.rounded_sm, + a.border, + animatedStyle, ]}> - - - - {message} - + + + + + + + {message} + + + + )} - + ) } diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx index 1f9eb479b..96798e61c 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -3,7 +3,7 @@ */ import React, {useEffect, useState} from 'react' -import {StyleSheet, Text, View} from 'react-native' +import {Pressable, StyleSheet, Text, View} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -43,6 +43,14 @@ export const ToastContainer: React.FC = ({}) => { style={styles.icon as FontAwesomeIconStyle} /> {activeToast.text} + { + setActiveToast(undefined) + }} + /> )} @@ -77,6 +85,13 @@ const styles = StyleSheet.create({ backgroundColor: '#000c', borderRadius: 10, }, + dismissBackdrop: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + }, icon: { color: '#fff', flexShrink: 0, -- cgit 1.4.1