set a custom return location after keyboard closes

This commit is contained in:
Samuel Newman
2026-02-27 11:49:38 +02:00
parent 865595c4ae
commit dbf367eccc
2 changed files with 83 additions and 22 deletions
+82 -22
View File
@@ -23,6 +23,7 @@ import {
type GestureUpdateEvent,
type PanGestureHandlerEventPayload,
} from 'react-native-gesture-handler'
import {KeyboardEvents} from 'react-native-keyboard-controller'
import Animated, {
clamp,
interpolate,
@@ -35,6 +36,7 @@ import Animated, {
type WithSpringConfig,
} from 'react-native-reanimated'
import {
type EdgeInsets,
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
@@ -110,6 +112,7 @@ export function Root({children}: {children: React.ReactNode}) {
const playHaptic = useHaptics()
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
const [measurement, setMeasurement] = useState<Measurement | null>(null)
const returnLocationSV = useSharedValue<{x: number; y: number} | null>(null)
const animationSV = useSharedValue(0)
const translationSV = useSharedValue(0)
const isFocused = useIsFocused()
@@ -142,6 +145,7 @@ export function Root({children}: {children: React.ReactNode}) {
({
isOpen: !!measurement && isFocused,
measurement,
returnLocationSV,
animationSV,
translationSV,
mode,
@@ -149,6 +153,8 @@ export function Root({children}: {children: React.ReactNode}) {
setMeasurement(evt)
setMode(mode)
animationSV.set(withSpring(1, SPRING_IN))
// reset return location
returnLocationSV.set(null)
},
close: () => {
animationSV.set(
@@ -156,6 +162,9 @@ export function Root({children}: {children: React.ReactNode}) {
if (finished) {
hoverablesSV.set({})
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)()
}
}),
@@ -194,6 +203,7 @@ export function Root({children}: {children: React.ReactNode}) {
}) satisfies ContextType,
[
measurement,
returnLocationSV,
setMeasurement,
onCompletedClose,
isFocused,
@@ -225,7 +235,7 @@ export function Root({children}: {children: React.ReactNode}) {
export function Trigger({children, label, contentLabel, style}: TriggerProps) {
const context = useContextMenuContext()
const playHaptic = useHaptics()
const {top: topInset} = useSafeAreaInsets()
const insets = useSafeAreaInsets()
const ref = useRef<View>(null)
const isFocused = useIsFocused()
const [image, setImage] = useState<string | null>(null)
@@ -237,23 +247,8 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
const open = useNonReactiveCallback(
async (mode: 'full' | 'auxiliary-only') => {
playHaptic()
Keyboard.dismiss()
const [measurement, capture] = await Promise.all([
new Promise<Measurement>(resolve => {
ref.current?.measureInWindow((x, y, width, height) =>
resolve({
x,
y:
y +
platform({
default: 0,
android: topInset, // not included in measurement
}),
width,
height,
}),
)
}),
measureView(ref.current, insets),
captureRef(ref, {result: 'data-uri'}).catch(err => {
logger.error(err instanceof Error ? err : String(err), {
message: 'Failed to capture image of context menu trigger',
@@ -262,16 +257,45 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
return '<failed capture>'
}),
])
Keyboard.dismiss()
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(() => {
return Gesture.Tap()
.numberOfTaps(2)
.hitSlop(HITSLOP_10)
.onEnd(() => open('auxiliary-only'))
.onEnd(() => void open('auxiliary-only'))
.runOnJS(true)
}, [open])
@@ -360,6 +384,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
animation={animationSV}
image={image}
measurement={measurement}
returnLocation={context.returnLocationSV}
onDisplay={() => {
if (pendingMeasurement) {
context.open(
@@ -384,6 +409,7 @@ function TriggerClone({
animation,
image,
measurement,
returnLocation,
onDisplay,
label,
}: {
@@ -391,14 +417,29 @@ function TriggerClone({
animation: SharedValue<number>
image: string
measurement: Measurement
returnLocation: SharedValue<{x: number; y: number} | null>
onDisplay: () => void
label: string
}) {
const {_} = useLingui()
const animatedStyles = useAnimatedStyle(() => ({
transform: [{translateY: translation.get() * animation.get()}],
}))
const animatedStyles = useAnimatedStyle(() => {
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(
(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(
evt:
| GestureStateChangeEvent<PanGestureHandlerEventPayload>
+1
View File
@@ -49,6 +49,7 @@ export type ContextType = {
translationSV: SharedValue<number>
mode: 'full' | 'auxiliary-only'
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
returnLocationSV: SharedValue<{x: number; y: number} | null>
close: () => void
registerHoverable: (
id: string,