From 5c1f21623da99492073ab7ea5912f330b02fc07d Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Sun, 22 Jun 2025 15:24:58 -0500 Subject: [PATCH] Ok working with no overlay and global gesture handler --- src/App.native.tsx | 13 +- src/components/Tooltip/index.tsx | 230 ++++++++++++------------ src/state/shell/GlobalGestureEvents.tsx | 86 +++++++++ 3 files changed, 206 insertions(+), 123 deletions(-) create mode 100644 src/state/shell/GlobalGestureEvents.tsx diff --git a/src/App.native.tsx b/src/App.native.tsx index 81d4a870e9..dc8d29fee2 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -73,6 +73,7 @@ import {Splash} from '#/Splash' import {BottomSheetProvider} from '../modules/bottom-sheet' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' +import {GlobalGestureEvents} from '#/state/shell/GlobalGestureEvents' SplashScreen.preventAutoHideAsync() if (isIOS) { @@ -154,11 +155,13 @@ function InnerApp() { - - - - - + + + + + + + diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index 3e36b5af46..b5ee915cdb 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -1,17 +1,17 @@ import { + useCallback, useMemo, useRef, createContext, useContext, useState, - isValidElement, } from 'react' -import {View, Modal, Dimensions, Pressable} from 'react-native' +import {View, Dimensions} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import flattenReactChildren from 'react-keyed-flatten-children' import {Portal} from '#/components/Portal' import {atoms as a, useTheme} from '#/alf' +import {useOnInteract} from '#/state/shell/GlobalGestureEvents' type Context = { visible: boolean @@ -25,7 +25,6 @@ type Context = { } | undefined targetRef: React.RefObject - targetElement: any } const TooltipContext = createContext({ @@ -33,7 +32,6 @@ const TooltipContext = createContext({ onVisibleChange: () => {}, targetMeasurements: undefined, targetRef: {current: null}, - targetElement: null, }) export function Outer({ @@ -80,11 +78,6 @@ export function Outer({ onVisibleChange, targetMeasurements: measurements, targetRef, - targetElement: flattenReactChildren(children).find(child => { - if (isValidElement(child) && child.type === Target) { - return true - } - }), }), [visible, onVisibleChange, measurements, targetRef], ) @@ -109,9 +102,35 @@ export function Target({ const TIP_SIZE = 12 export function Content({children}: {children: React.ReactNode}) { - const t = useTheme() - const {visible, onVisibleChange, targetMeasurements, targetElement} = + const {visible, onVisibleChange, targetMeasurements} = useContext(TooltipContext) + const requestClose = useCallback(() => { + onVisibleChange(false) + }, [onVisibleChange]) + + if (!visible || !targetMeasurements) return null + + return ( + + + {children} + + + ) +} + +function Tooltip({ + children, + requestClose, + targetMeasurements, +}: { + children: React.ReactNode + requestClose: () => void + targetMeasurements: Context['targetMeasurements'] +}) { + const t = useTheme() const [ready, setReady] = useState(false) const [measurements, setMeasurements] = useState< | { @@ -121,18 +140,13 @@ export function Content({children}: {children: React.ReactNode}) { | undefined >(undefined) const safe = useSafeAreaInsets() - - const requestClose = () => { - onVisibleChange(false) - setReady(false) - setMeasurements(undefined) - } - - const {positionTop, contentLeft, tipTop, tipLeft} = useMemo(() => { + const coords = useMemo(() => { if (!targetMeasurements || !measurements) return { - positionTop: 0, - contentLeft: 0, + top: 0, + bottom: 0, + left: 0, + right: 0, tipTop: 0, tipLeft: 0, } @@ -145,118 +159,98 @@ export function Content({children}: {children: React.ReactNode}) { const minLeft = a.px_xl.paddingLeft const maxLeft = ww - minLeft - let positionTop = targetMeasurements.y + targetMeasurements.height - let contentLeft = Math.max( + let top = targetMeasurements.y + targetMeasurements.height + let left = Math.max( minLeft, targetMeasurements.x + targetMeasurements.width / 2 - cw / 2, ) let tipTop = (TIP_SIZE / 2) * -1 - let tipLeft = - targetMeasurements.x + targetMeasurements.width / 2 - TIP_SIZE / 2 - if (contentLeft + cw > maxLeft) { - contentLeft -= contentLeft + cw - maxLeft + if (left + cw > maxLeft) { + left -= left + cw - maxLeft } - positionTop += TIP_SIZE / 3 + let tipLeft = + targetMeasurements.x - left + targetMeasurements.width / 2 - TIP_SIZE / 2 + + top += TIP_SIZE / 3 return { - positionTop, - contentLeft, + top, + bottom: top + ch, + left, + right: left + cw, tipTop, tipLeft, } }, [targetMeasurements, measurements, safe]) - if (!visible || !targetMeasurements) return null + const requestCloseWrapped = useCallback(() => { + setReady(false) + setMeasurements(undefined) + requestClose() + }, [requestClose]) + + useOnInteract( + useCallback( + e => { + const {x, y} = e + const isInside = + x > coords.left && + x < coords.right && + y > coords.top && + y < coords.bottom + + if (!isInside) { + requestCloseWrapped() + } + }, + [coords, requestCloseWrapped], + ), + ) return ( - - {}} // TODO - style={[a.absolute, a.inset_0]}> - {/* Backdrop */} - + + {/* The triangle "tip" */} + - {/* Replica of the target element */} - - {targetElement} - - - {/* Outer content container */} - - {/* The triangle "tip" */} - - - {/* The content itself */} - { - setReady(true) - setMeasurements({ - width: e.nativeEvent.layout.width, - height: e.nativeEvent.layout.height, - }) - }}> - {children} - - - - + {/* The content itself */} + { + setMeasurements({ + width: e.nativeEvent.layout.width, + height: e.nativeEvent.layout.height, + }) + setReady(true) + }}> + {children} + + ) } diff --git a/src/state/shell/GlobalGestureEvents.tsx b/src/state/shell/GlobalGestureEvents.tsx new file mode 100644 index 0000000000..8c3c4eb0c7 --- /dev/null +++ b/src/state/shell/GlobalGestureEvents.tsx @@ -0,0 +1,86 @@ +import {createContext, useContext, useMemo, useEffect, useRef, useState} from 'react' +import EventEmitter from 'eventemitter3' +import { + GestureDetector, + Gesture, + GestureStateChangeEvent, + GestureUpdateEvent, + PanGestureHandlerEventPayload, +} from 'react-native-gesture-handler' + +const events = new EventEmitter<{ + begin: GestureStateChangeEvent + update: GestureUpdateEvent + end: GestureStateChangeEvent + finalize: GestureStateChangeEvent +}>() + +const Context = createContext<{ + register: () => void + unregister: () => void +}>({ + register: () => {}, + unregister: () => {}, +}) + +export function GlobalGestureEvents({ + children, +}: { + children: React.ReactNode +}): React.ReactElement { + const refCount = useRef(0) + const [enabled, setEnabled] = useState(false) + const ctx = useMemo(() => ({ + register() { + refCount.current += 1 + if (refCount.current === 1) { + setEnabled(true) + } + }, + unregister() { + refCount.current -= 1 + if (refCount.current === 0) { + setEnabled(false) + } + }, + }), [setEnabled]) + const gesture = Gesture.Pan() + .runOnJS(true) + .enabled(enabled) + .simultaneousWithExternalGesture() + .onBegin(e => { + events.emit('begin', e) + }) + .onUpdate(e => { + events.emit('update', e) + }) + .onEnd(e => { + events.emit('end', e) + }) + .onFinalize(e => { + events.emit('finalize', e) + }) + + return ( + + {children} + + ) +} + +export function useOnInteract( + onInteract: ( + e: GestureStateChangeEvent, + ) => void, +) { + const ctx = useContext(Context) + useEffect(() => { + ctx.register() + events.on('begin', onInteract) + + return () => { + ctx.unregister() + events.off('begin', onInteract) + } + }, [ctx, onInteract]) +}