set a custom return location after keyboard closes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user