ContextMenu - return item to the right location if keyboard hides (#9963)

This commit is contained in:
Samuel Newman
2026-02-27 22:19:04 +00:00
committed by GitHub
parent d4357b2cb8
commit 7c9f05a2af
3 changed files with 93 additions and 32 deletions
+7 -7
View File
@@ -1,4 +1,4 @@
import React from 'react' import {createContext, useContext} from 'react'
import { import {
type ContextType, type ContextType,
@@ -6,17 +6,17 @@ import {
type MenuContextType, type MenuContextType,
} from '#/components/ContextMenu/types' } from '#/components/ContextMenu/types'
export const Context = React.createContext<ContextType | null>(null) export const Context = createContext<ContextType | null>(null)
Context.displayName = 'ContextMenuContext' Context.displayName = 'ContextMenuContext'
export const MenuContext = React.createContext<MenuContextType | null>(null) export const MenuContext = createContext<MenuContextType | null>(null)
MenuContext.displayName = 'ContextMenuMenuContext' MenuContext.displayName = 'ContextMenuMenuContext'
export const ItemContext = React.createContext<ItemContextType | null>(null) export const ItemContext = createContext<ItemContextType | null>(null)
ItemContext.displayName = 'ContextMenuItemContext' ItemContext.displayName = 'ContextMenuItemContext'
export function useContextMenuContext() { export function useContextMenuContext() {
const context = React.useContext(Context) const context = useContext(Context)
if (!context) { if (!context) {
throw new Error( throw new Error(
@@ -28,7 +28,7 @@ export function useContextMenuContext() {
} }
export function useContextMenuMenuContext() { export function useContextMenuMenuContext() {
const context = React.useContext(MenuContext) const context = useContext(MenuContext)
if (!context) { if (!context) {
throw new Error( throw new Error(
@@ -40,7 +40,7 @@ export function useContextMenuMenuContext() {
} }
export function useContextMenuItemContext() { export function useContextMenuItemContext() {
const context = React.useContext(ItemContext) const context = useContext(ItemContext)
if (!context) { if (!context) {
throw new Error( throw new Error(
+85 -25
View File
@@ -23,6 +23,7 @@ import {
type GestureUpdateEvent, type GestureUpdateEvent,
type PanGestureHandlerEventPayload, type PanGestureHandlerEventPayload,
} from 'react-native-gesture-handler' } from 'react-native-gesture-handler'
import {KeyboardEvents} from 'react-native-keyboard-controller'
import Animated, { import Animated, {
clamp, clamp,
interpolate, interpolate,
@@ -35,6 +36,7 @@ import Animated, {
type WithSpringConfig, type WithSpringConfig,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import { import {
type EdgeInsets,
useSafeAreaFrame, useSafeAreaFrame,
useSafeAreaInsets, useSafeAreaInsets,
} from 'react-native-safe-area-context' } from 'react-native-safe-area-context'
@@ -81,9 +83,9 @@ export {
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
const SPRING_IN: WithSpringConfig = { const SPRING_IN: WithSpringConfig = {
mass: IS_IOS ? 1.25 : 0.75, mass: 0.75,
damping: 50, damping: 300,
stiffness: 1100, stiffness: 1200,
restDisplacementThreshold: 0.01, restDisplacementThreshold: 0.01,
} }
@@ -110,6 +112,7 @@ export function Root({children}: {children: React.ReactNode}) {
const playHaptic = useHaptics() const playHaptic = useHaptics()
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full') const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
const [measurement, setMeasurement] = useState<Measurement | null>(null) const [measurement, setMeasurement] = useState<Measurement | null>(null)
const returnLocationSV = useSharedValue<{x: number; y: number} | null>(null)
const animationSV = useSharedValue(0) const animationSV = useSharedValue(0)
const translationSV = useSharedValue(0) const translationSV = useSharedValue(0)
const isFocused = useIsFocused() const isFocused = useIsFocused()
@@ -142,6 +145,7 @@ export function Root({children}: {children: React.ReactNode}) {
({ ({
isOpen: !!measurement && isFocused, isOpen: !!measurement && isFocused,
measurement, measurement,
returnLocationSV,
animationSV, animationSV,
translationSV, translationSV,
mode, mode,
@@ -149,6 +153,8 @@ export function Root({children}: {children: React.ReactNode}) {
setMeasurement(evt) setMeasurement(evt)
setMode(mode) setMode(mode)
animationSV.set(withSpring(1, SPRING_IN)) animationSV.set(withSpring(1, SPRING_IN))
// reset return location
returnLocationSV.set(null)
}, },
close: () => { close: () => {
animationSV.set( animationSV.set(
@@ -156,6 +162,9 @@ export function Root({children}: {children: React.ReactNode}) {
if (finished) { if (finished) {
hoverablesSV.set({}) hoverablesSV.set({})
translationSV.set(0) translationSV.set(0)
// note: return location has to be reset on open,
// rather than on close, otherwise there's a flicker
// where the reanimated update is faster than the react render
runOnJS(onCompletedClose)() runOnJS(onCompletedClose)()
} }
}), }),
@@ -194,6 +203,7 @@ export function Root({children}: {children: React.ReactNode}) {
}) satisfies ContextType, }) satisfies ContextType,
[ [
measurement, measurement,
returnLocationSV,
setMeasurement, setMeasurement,
onCompletedClose, onCompletedClose,
isFocused, isFocused,
@@ -225,7 +235,7 @@ export function Root({children}: {children: React.ReactNode}) {
export function Trigger({children, label, contentLabel, style}: TriggerProps) { export function Trigger({children, label, contentLabel, style}: TriggerProps) {
const context = useContextMenuContext() const context = useContextMenuContext()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const {top: topInset} = useSafeAreaInsets() const insets = useSafeAreaInsets()
const ref = useRef<View>(null) const ref = useRef<View>(null)
const isFocused = useIsFocused() const isFocused = useIsFocused()
const [image, setImage] = useState<string | null>(null) const [image, setImage] = useState<string | null>(null)
@@ -237,23 +247,8 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
const open = useNonReactiveCallback( const open = useNonReactiveCallback(
async (mode: 'full' | 'auxiliary-only') => { async (mode: 'full' | 'auxiliary-only') => {
playHaptic() playHaptic()
Keyboard.dismiss()
const [measurement, capture] = await Promise.all([ const [measurement, capture] = await Promise.all([
new Promise<Measurement>(resolve => { measureView(ref.current, insets),
ref.current?.measureInWindow((x, y, width, height) =>
resolve({
x,
y:
y +
platform({
default: 0,
android: topInset, // not included in measurement
}),
width,
height,
}),
)
}),
captureRef(ref, {result: 'data-uri'}).catch(err => { captureRef(ref, {result: 'data-uri'}).catch(err => {
logger.error(err instanceof Error ? err : String(err), { logger.error(err instanceof Error ? err : String(err), {
message: 'Failed to capture image of context menu trigger', message: 'Failed to capture image of context menu trigger',
@@ -262,16 +257,45 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
return '<failed capture>' return '<failed capture>'
}), }),
]) ])
Keyboard.dismiss()
setImage(capture) setImage(capture)
setPendingMeasurement({measurement, mode}) if (measurement) {
setPendingMeasurement({measurement, mode})
}
}, },
) )
// after keyboard hides, the position might change - set a return location
useEffect(() => {
if (context.isOpen && context.measurement) {
const hide = KeyboardEvents.addListener('keyboardDidHide', () => {
measureView(ref.current, insets)
.then(newMeasurement => {
if (!newMeasurement || !context.measurement) return
if (
newMeasurement.x !== context.measurement.x ||
newMeasurement.y !== context.measurement.y
) {
context.returnLocationSV.set({
x: newMeasurement.x,
y: newMeasurement.y,
})
}
})
.catch(() => {})
})
return () => {
hide.remove()
}
}
}, [context, insets])
const doubleTapGesture = useMemo(() => { const doubleTapGesture = useMemo(() => {
return Gesture.Tap() return Gesture.Tap()
.numberOfTaps(2) .numberOfTaps(2)
.hitSlop(HITSLOP_10) .hitSlop(HITSLOP_10)
.onEnd(() => open('auxiliary-only')) .onEnd(() => void open('auxiliary-only'))
.runOnJS(true) .runOnJS(true)
}, [open]) }, [open])
@@ -360,6 +384,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
animation={animationSV} animation={animationSV}
image={image} image={image}
measurement={measurement} measurement={measurement}
returnLocation={context.returnLocationSV}
onDisplay={() => { onDisplay={() => {
if (pendingMeasurement) { if (pendingMeasurement) {
context.open( context.open(
@@ -384,6 +409,7 @@ function TriggerClone({
animation, animation,
image, image,
measurement, measurement,
returnLocation,
onDisplay, onDisplay,
label, label,
}: { }: {
@@ -391,14 +417,29 @@ function TriggerClone({
animation: SharedValue<number> animation: SharedValue<number>
image: string image: string
measurement: Measurement measurement: Measurement
returnLocation: SharedValue<{x: number; y: number} | null>
onDisplay: () => void onDisplay: () => void
label: string label: string
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const animatedStyles = useAnimatedStyle(() => ({ const animatedStyles = useAnimatedStyle(() => {
transform: [{translateY: translation.get() * animation.get()}], const anim = animation.get()
})) const ret = returnLocation.get()
const returnOffsetX = ret
? interpolate(anim, [0, 1], [ret.x - measurement.x, 0])
: 0
const returnOffsetY = ret
? interpolate(anim, [0, 1], [ret.y - measurement.y, 0])
: 0
return {
transform: [
{translateX: returnOffsetX},
{translateY: translation.get() * anim + returnOffsetY},
],
}
})
const handleError = useCallback( const handleError = useCallback(
(evt: ImageErrorEventData) => { (evt: ImageErrorEventData) => {
@@ -874,6 +915,25 @@ export function Divider() {
) )
} }
function measureView(view: View | null, insets: EdgeInsets) {
if (!view) return Promise.resolve(null)
return new Promise<Measurement>(resolve => {
view?.measureInWindow((x, y, width, height) =>
resolve({
x,
y:
y +
platform({
default: 0,
android: insets.top, // not included in measurement
}),
width,
height,
}),
)
})
}
function getHoveredHoverable( function getHoveredHoverable(
evt: evt:
| GestureStateChangeEvent<PanGestureHandlerEventPayload> | GestureStateChangeEvent<PanGestureHandlerEventPayload>
+1
View File
@@ -49,6 +49,7 @@ export type ContextType = {
translationSV: SharedValue<number> translationSV: SharedValue<number>
mode: 'full' | 'auxiliary-only' mode: 'full' | 'auxiliary-only'
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
returnLocationSV: SharedValue<{x: number; y: number} | null>
close: () => void close: () => void
registerHoverable: ( registerHoverable: (
id: string, id: string,