diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift index 15993ef29e..e6ebef1d33 100644 --- a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift +++ b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift @@ -9,7 +9,7 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate { } } - private var rctScrollView: RCTScrollView? + private var scrollView: UIScrollView? private var rctRefreshCtrl: RCTRefreshControl? private var cancelGestureRecognizers: [UIGestureRecognizer]? private var animTimer: Timer? @@ -68,7 +68,7 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate { } @IBAction func callOnPan(_ sender: UIPanGestureRecognizer) { - guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else { + guard let sv = self.scrollView else { return } @@ -113,7 +113,7 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate { } func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) { - guard let sv = self.rctScrollView?.scrollView else { + guard let sv = self.scrollView else { return } @@ -160,32 +160,49 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate { return offset } + + private func findScrollView(in view: UIView, foundCount: Int) -> UIScrollView? { + var foundCount = foundCount + if let sv = view as? UIScrollView { return sv } + for child in view.subviews { + if let found = findScrollView(in: child, foundCount: foundCount) { + if foundCount == 1 { + print("found sv: \(found)") +// return found + } else { + print("found sv: \(found)") + foundCount += 1 + } + } + } + return nil + } func tryFindScrollView() { - guard let scrollViewTag = scrollViewTag else { - return - } - // Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer. // Otherwise we might end up with duplicates when we switch back to that scrollview. self.removeCancelGestureRecognizers() + + guard let sv = self.findScrollView(in: self.superview!.superview!.superview!, foundCount: 0) else { + print("⚠️ ExpoScrollForwarder: couldn’t find UIScrollView under tag \(tag)") + return + } - self.rctScrollView = self.appContext? - .findView(withTag: scrollViewTag, ofType: RCTScrollView.self) - self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl + self.scrollView = sv + self.rctRefreshCtrl = sv.refreshControl as? RCTRefreshControl self.addCancelGestureRecognizers() } func addCancelGestureRecognizers() { self.cancelGestureRecognizers?.forEach { r in - self.rctScrollView?.scrollView?.addGestureRecognizer(r) + self.scrollView?.addGestureRecognizer(r) } } func removeCancelGestureRecognizers() { self.cancelGestureRecognizers?.forEach { r in - self.rctScrollView?.scrollView?.removeGestureRecognizer(r) + self.scrollView?.removeGestureRecognizer(r) } } @@ -202,7 +219,7 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate { } func scrollToOffset(_ offset: Int, animated: Bool = true) { - self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated) + self.scrollView?.scrollRectToVisible(CGRect(x: 0, y: offset, width: 0, height: 0), animated: animated) } func stopTimer() { diff --git a/src/App.native.tsx b/src/App.native.tsx index 8bcd5143d2..ea50fdfb9c 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -60,7 +60,6 @@ import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-repli import {Provider as TrendingConfigProvider} from '#/state/trending-config' import {TestCtrls} from '#/view/com/testing/TestCtrls' import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' -import {ToastContainer} from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast' import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' @@ -157,7 +156,6 @@ function InnerApp() { - diff --git a/src/App.web.tsx b/src/App.web.tsx index ac15c99503..bbe23e5a56 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -51,7 +51,7 @@ import {Provider as TrendingConfigProvider} from '#/state/trending-config' import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' -import {ToastContainer} from '#/view/com/util/Toast' +import {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 4a2ccf5ba5..56c6780ad3 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,7 +1,13 @@ import {useEffect, useMemo, useRef, useState} from 'react' import {AccessibilityInfo, View} from 'react-native' -import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import { + Gesture, + GestureDetector, + GestureHandlerRootView, +} from 'react-native-gesture-handler' import Animated, { + FadeInUp, + FadeOutUp, runOnJS, useAnimatedReaction, useAnimatedStyle, @@ -9,13 +15,12 @@ import Animated, { withDecay, withSpring, } from 'react-native-reanimated' +import RootSiblings from 'react-native-root-siblings' import {useSafeAreaInsets} from 'react-native-safe-area-context' import { FontAwesomeIcon, type Props as FontAwesomeProps, } from '@fortawesome/react-native-fontawesome' -import EventEmitter from 'eventemitter3' -import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a, useTheme} from '#/alf' @@ -23,37 +28,6 @@ import {Text} from '#/components/Typography' const TIMEOUT = 2e3 -type ToastProps = { - message: string - icon: FontAwesomeProps['icon'] -} - -const SHOW_TOAST = 'show-toast' - -const ToastEventEmitter = new EventEmitter() - -export function ToastContainer() { - const [toasts, setToasts] = useState<(ToastProps & {key: string})[]>([]) - - useEffect(() => { - const listener = ({message, icon}: ToastProps) => { - setToasts(prev => [...prev, {message, icon, key: nanoid()}]) - } - ToastEventEmitter.on(SHOW_TOAST, listener) - return () => { - ToastEventEmitter.off(SHOW_TOAST, listener) - } - }, []) - - return toasts.map(toast => ( - setToasts(prev => prev.filter(t => t !== toast))} - /> - )) -} - export function show( message: string, icon: FontAwesomeProps['icon'] = 'check', @@ -62,7 +36,9 @@ export function show( return } AccessibilityInfo.announceForAccessibility(message) - ToastEventEmitter.emit(SHOW_TOAST, {message, icon}) + const item = new RootSiblings( + item.destroy()} />, + ) } function Toast({ @@ -77,23 +53,19 @@ function Toast({ const t = useTheme() const {top} = useSafeAreaInsets() const isPanning = useSharedValue(false) - const animationTranslateY = useSharedValue(-200) 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 = () => { - 'worklet' - animationTranslateY.set( - withSpring( - -200, - { - damping: 100, - stiffness: 800, - restDisplacementThreshold: 0.01, - }, - () => runOnJS(destroy)(), - ), - ) + setAlive(false) + setTimeout(() => { + destroy() + }, 1e3) } const destroyTimeoutRef = useRef>() @@ -106,15 +78,8 @@ function Toast({ }) useEffect(() => { - animationTranslateY.set( - withSpring(0, { - damping: 100, - stiffness: 800, - restDisplacementThreshold: 0.01, - }), - ) hideAndDestroyAfterTimeout() - }, [hideAndDestroyAfterTimeout, animationTranslateY]) + }, [hideAndDestroyAfterTimeout]) const panGesture = useMemo(() => { return Gesture.Pan() @@ -123,15 +88,18 @@ function Toast({ .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) { @@ -147,15 +115,15 @@ function Toast({ }) } else { dismissSwipeTranslateY.value = withSpring(0, { - damping: 75, - stiffness: 1000, - restDisplacementThreshold: 0.01, + stiffness: 500, + damping: 50, }) } }) }, [ dismissSwipeTranslateY, isPanning, + alive, hideAndDestroyAfterTimeout, pauseDestroy, ]) @@ -175,7 +143,7 @@ function Toast({ ) const animatedStyle = useAnimatedStyle(() => { - const translation = dismissSwipeTranslateY.get() + animationTranslateY.get() + const translation = dismissSwipeTranslateY.get() return { transform: [ { @@ -186,51 +154,62 @@ function Toast({ }) return ( - setCardHeight(evt.nativeEvent.layout.height)} - accessibilityRole="alert" - accessible={true} - accessibilityLabel={message} - accessibilityHint="" - onAccessibilityEscape={hideAndDestroyImmediately} - style={[ - a.absolute, - {top: topOffset, left: 16, right: 16}, - a.flex_1, - t.name === 'dark' ? t.atoms.bg_contrast_25 : t.atoms.bg, - a.shadow_lg, - t.atoms.border_contrast_medium, - a.rounded_sm, - a.border, - animatedStyle, - ]}> - - - + {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}, - a.align_center, - a.justify_center, - { - backgroundColor: - t.name === 'dark' ? t.palette.black : t.palette.primary_50, - }, + a.flex_1, + t.name === 'dark' ? t.atoms.bg_contrast_25 : 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 4da1c5d235..7e22fcefc1 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -9,6 +9,7 @@ import { type FontAwesomeIconStyle, type Props as FontAwesomeProps, } from '@fortawesome/react-native-fontawesome' +import type React from 'react' const DURATION = 3500 @@ -25,7 +26,8 @@ let toastTimeout: NodeJS.Timeout | undefined // components // = -export function ToastContainer() { +type ToastContainerProps = {} +export const ToastContainer: React.FC = ({}) => { const [activeToast, setActiveToast] = useState() useEffect(() => { globalSetActiveToast = (t: ActiveToast | undefined) => {