Ok working with no overlay and global gesture handler

This commit is contained in:
Eric Bailey
2025-06-22 15:24:58 -05:00
parent 19ec70b8f0
commit 5c1f21623d
3 changed files with 206 additions and 123 deletions
+8 -5
View File
@@ -73,6 +73,7 @@ import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet' import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
import {GlobalGestureEvents} from '#/state/shell/GlobalGestureEvents'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
if (isIOS) { if (isIOS) {
@@ -154,11 +155,13 @@ function InnerApp() {
<HideBottomBarBorderProvider> <HideBottomBarBorderProvider>
<GestureHandlerRootView <GestureHandlerRootView
style={s.h100pct}> style={s.h100pct}>
<IntentDialogProvider> <GlobalGestureEvents>
<TestCtrls /> <IntentDialogProvider>
<Shell /> <TestCtrls />
<NuxDialogs /> <Shell />
</IntentDialogProvider> <NuxDialogs />
</IntentDialogProvider>
</GlobalGestureEvents>
</GestureHandlerRootView> </GestureHandlerRootView>
</HideBottomBarBorderProvider> </HideBottomBarBorderProvider>
</ServiceAccountManager> </ServiceAccountManager>
+112 -118
View File
@@ -1,17 +1,17 @@
import { import {
useCallback,
useMemo, useMemo,
useRef, useRef,
createContext, createContext,
useContext, useContext,
useState, useState,
isValidElement,
} from 'react' } 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 {useSafeAreaInsets} from 'react-native-safe-area-context'
import flattenReactChildren from 'react-keyed-flatten-children'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useOnInteract} from '#/state/shell/GlobalGestureEvents'
type Context = { type Context = {
visible: boolean visible: boolean
@@ -25,7 +25,6 @@ type Context = {
} }
| undefined | undefined
targetRef: React.RefObject<View> targetRef: React.RefObject<View>
targetElement: any
} }
const TooltipContext = createContext<Context>({ const TooltipContext = createContext<Context>({
@@ -33,7 +32,6 @@ const TooltipContext = createContext<Context>({
onVisibleChange: () => {}, onVisibleChange: () => {},
targetMeasurements: undefined, targetMeasurements: undefined,
targetRef: {current: null}, targetRef: {current: null},
targetElement: null,
}) })
export function Outer({ export function Outer({
@@ -80,11 +78,6 @@ export function Outer({
onVisibleChange, onVisibleChange,
targetMeasurements: measurements, targetMeasurements: measurements,
targetRef, targetRef,
targetElement: flattenReactChildren(children).find(child => {
if (isValidElement(child) && child.type === Target) {
return true
}
}),
}), }),
[visible, onVisibleChange, measurements, targetRef], [visible, onVisibleChange, measurements, targetRef],
) )
@@ -109,9 +102,35 @@ export function Target({
const TIP_SIZE = 12 const TIP_SIZE = 12
export function Content({children}: {children: React.ReactNode}) { export function Content({children}: {children: React.ReactNode}) {
const t = useTheme() const {visible, onVisibleChange, targetMeasurements} =
const {visible, onVisibleChange, targetMeasurements, targetElement} =
useContext(TooltipContext) useContext(TooltipContext)
const requestClose = useCallback(() => {
onVisibleChange(false)
}, [onVisibleChange])
if (!visible || !targetMeasurements) return null
return (
<Portal>
<Tooltip
targetMeasurements={targetMeasurements}
requestClose={requestClose}>
{children}
</Tooltip>
</Portal>
)
}
function Tooltip({
children,
requestClose,
targetMeasurements,
}: {
children: React.ReactNode
requestClose: () => void
targetMeasurements: Context['targetMeasurements']
}) {
const t = useTheme()
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [measurements, setMeasurements] = useState< const [measurements, setMeasurements] = useState<
| { | {
@@ -121,18 +140,13 @@ export function Content({children}: {children: React.ReactNode}) {
| undefined | undefined
>(undefined) >(undefined)
const safe = useSafeAreaInsets() const safe = useSafeAreaInsets()
const coords = useMemo(() => {
const requestClose = () => {
onVisibleChange(false)
setReady(false)
setMeasurements(undefined)
}
const {positionTop, contentLeft, tipTop, tipLeft} = useMemo(() => {
if (!targetMeasurements || !measurements) if (!targetMeasurements || !measurements)
return { return {
positionTop: 0, top: 0,
contentLeft: 0, bottom: 0,
left: 0,
right: 0,
tipTop: 0, tipTop: 0,
tipLeft: 0, tipLeft: 0,
} }
@@ -145,118 +159,98 @@ export function Content({children}: {children: React.ReactNode}) {
const minLeft = a.px_xl.paddingLeft const minLeft = a.px_xl.paddingLeft
const maxLeft = ww - minLeft const maxLeft = ww - minLeft
let positionTop = targetMeasurements.y + targetMeasurements.height let top = targetMeasurements.y + targetMeasurements.height
let contentLeft = Math.max( let left = Math.max(
minLeft, minLeft,
targetMeasurements.x + targetMeasurements.width / 2 - cw / 2, targetMeasurements.x + targetMeasurements.width / 2 - cw / 2,
) )
let tipTop = (TIP_SIZE / 2) * -1 let tipTop = (TIP_SIZE / 2) * -1
let tipLeft =
targetMeasurements.x + targetMeasurements.width / 2 - TIP_SIZE / 2
if (contentLeft + cw > maxLeft) { if (left + cw > maxLeft) {
contentLeft -= contentLeft + 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 { return {
positionTop, top,
contentLeft, bottom: top + ch,
left,
right: left + cw,
tipTop, tipTop,
tipLeft, tipLeft,
} }
}, [targetMeasurements, measurements, safe]) }, [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 ( return (
<Portal> <View
<Modal style={[
transparent a.absolute,
onRequestClose={() => {}} // TODO a.align_start,
style={[a.absolute, a.inset_0]}> {
{/* Backdrop */} width: 200,
<Pressable opacity: ready ? 1 : 0,
style={[ top: coords.top,
a.absolute, left: coords.left,
a.inset_0, },
t.atoms.bg, ]}>
{ {/* The triangle "tip" */}
opacity: 0.4, <View
}, style={[
]} a.absolute,
onPress={requestClose} a.top_0,
/> a.z_10,
t.atoms.bg,
{
borderTopLeftRadius: a.rounded_xs.borderRadius,
width: TIP_SIZE,
height: TIP_SIZE,
transform: [{rotate: '45deg'}],
top: coords.tipTop,
left: coords.tipLeft,
},
]}
/>
{/* Replica of the target element */} {/* The content itself */}
<View <View
style={[ style={[a.px_md, a.py_sm, a.rounded_sm, t.atoms.bg, t.atoms.shadow_sm]}
a.absolute, onLayout={e => {
{ setMeasurements({
top: targetMeasurements.y, width: e.nativeEvent.layout.width,
left: targetMeasurements.x, height: e.nativeEvent.layout.height,
height: targetMeasurements.height, })
width: targetMeasurements.width, setReady(true)
}, }}>
]}> {children}
{targetElement} </View>
</View> </View>
{/* Outer content container */}
<View
style={[
a.absolute,
a.align_start,
{
opacity: ready ? 1 : 0,
top: positionTop,
left: 0,
right: 0,
},
]}>
{/* The triangle "tip" */}
<View
style={[
a.absolute,
a.top_0,
a.z_10,
t.atoms.bg,
{
borderTopLeftRadius: a.rounded_xs.borderRadius,
width: TIP_SIZE,
height: TIP_SIZE,
transform: [{rotate: '45deg'}],
top: tipTop,
left: tipLeft,
},
]}
/>
{/* The content itself */}
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
t.atoms.bg,
t.atoms.shadow_sm,
{
maxWidth: 200,
left: contentLeft,
},
]}
onLayout={e => {
setReady(true)
setMeasurements({
width: e.nativeEvent.layout.width,
height: e.nativeEvent.layout.height,
})
}}>
{children}
</View>
</View>
</Modal>
</Portal>
) )
} }
+86
View File
@@ -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<PanGestureHandlerEventPayload>
update: GestureUpdateEvent<PanGestureHandlerEventPayload>
end: GestureStateChangeEvent<PanGestureHandlerEventPayload>
finalize: GestureStateChangeEvent<PanGestureHandlerEventPayload>
}>()
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 (
<Context.Provider value={ctx}>
<GestureDetector gesture={gesture}>{children}</GestureDetector>
</Context.Provider>
)
}
export function useOnInteract(
onInteract: (
e: GestureStateChangeEvent<PanGestureHandlerEventPayload>,
) => void,
) {
const ctx = useContext(Context)
useEffect(() => {
ctx.register()
events.on('begin', onInteract)
return () => {
ctx.unregister()
events.off('begin', onInteract)
}
}, [ctx, onInteract])
}