ContextMenu - return item to the right location if keyboard hides (#9963)
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user