Skip to content

Commit 7c6aa7b

Browse files
committed
[Android] Add hover callbacks to Touchable
## Description `Touchable` gains `onHoverIn`/`onHoverOut`, reported for a mouse, trackpad cursor or hovering stylus. The button already tracked hover to drive its animation — this exposes it to JS as the new `onButtonHoverIn`/`onButtonHoverOut` direct events, omitted from `RawButtonProps` since the deprecated buttons never report hover. - Reporting follows `isHovered && isEnabled`, the same expression that drives the hover visual, so disabling a hovered button reports a hover-out and re-enabling it with the pointer still inside reports a hover-in. - No hover events arrive while the button is held, so the transitions are derived from the touch stream during a press — gated so a press can only maintain a hover that was already open, never open one. The pointer type carries over from the previous sample, since those events belong to the pressing pointer. - The payload is snapshotted when the pointer is seen, because the events outlive the `MotionEvent` behind them. ## Test plan `yarn test` covers the prop forwarding; hover itself needs a device or emulator with a mouse, trackpad or stylus. <details> <summary>Example code</summary> ```tsx import React, { useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { GestureHandlerRootView, Touchable, } from 'react-native-gesture-handler'; export default function Example() { const [log, setLog] = useState<string[]>([]); const callbacks = (source: string) => ({ onHoverIn: () => setLog((l) => [`${source} onHoverIn`, ...l]), onHoverOut: () => setLog((l) => [`${source} onHoverOut`, ...l]), onPressIn: () => setLog((l) => [`${source} onPressIn`, ...l]), onPressOut: () => setLog((l) => [`${source} onPressOut`, ...l]), }); return ( <GestureHandlerRootView style={styles.container}> <View style={styles.row}> <Touchable style={styles.box} {...callbacks('Touchable')}> <Text style={styles.text}>Touchable</Text> </Touchable> <Pressable style={styles.box} {...callbacks('Pressable')}> <Text style={styles.text}>Pressable</Text> </Pressable> </View> {log.slice(0, 12).map((entry, i) => ( <Text key={i}>{entry}</Text> ))} </GestureHandlerRootView> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 24 }, row: { flexDirection: 'row', gap: 24, marginBottom: 24 }, box: { width: 120, height: 120, alignItems: 'center', justifyContent: 'center', backgroundColor: '#6941C6', }, text: { color: 'white' }, }); ``` </details>
1 parent 41d8392 commit 7c6aa7b

10 files changed

Lines changed: 303 additions & 41 deletions

File tree

‎packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt‎

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
package com.swmansion.gesturehandler.core
22

3-
import android.app.Activity
43
import android.content.Context
5-
import android.content.ContextWrapper
64
import android.graphics.PointF
75
import android.view.MotionEvent
86
import android.view.MotionEvent.PointerCoords
97
import android.view.MotionEvent.PointerProperties
108
import android.view.View
119
import com.facebook.react.bridge.Arguments
12-
import com.facebook.react.bridge.ReactContext
1310
import com.facebook.react.bridge.ReadableMap
1411
import com.facebook.react.bridge.ReadableType
1512
import com.facebook.react.bridge.UiThreadUtil
@@ -20,6 +17,8 @@ import com.swmansion.gesturehandler.RNSVGHitTester
2017
import com.swmansion.gesturehandler.react.RNGestureHandlerDetectorView
2118
import com.swmansion.gesturehandler.react.events.RNGestureHandlerTouchEvent
2219
import com.swmansion.gesturehandler.react.events.eventbuilders.GestureHandlerEventDataBuilder
20+
import com.swmansion.gesturehandler.react.findActivity
21+
import com.swmansion.gesturehandler.react.getPointerType
2322
import com.swmansion.gesturehandler.react.isHoverAction
2423
import java.lang.IllegalStateException
2524
import java.util.*
@@ -219,7 +218,7 @@ open class GestureHandler {
219218
this.view = view
220219
this.orchestrator = orchestrator
221220

222-
val content = getActivity(view?.context)?.findViewById<View>(android.R.id.content)
221+
val content = view?.context.findActivity()?.findViewById<View>(android.R.id.content)
223222
if (content != null) {
224223
content.getLocationOnScreen(windowOffset)
225224
} else {
@@ -232,13 +231,6 @@ open class GestureHandler {
232231

233232
protected open fun onPrepare() {}
234233

235-
private fun getActivity(context: Context?): Activity? = when (context) {
236-
is ReactContext -> context.currentActivity
237-
is Activity -> context
238-
is ContextWrapper -> getActivity(context.baseContext)
239-
else -> null
240-
}
241-
242234
private fun findNextLocalPointerId(): Int {
243235
var localPointerId = 0
244236
while (localPointerId < trackedPointersIDsCount) {
@@ -899,14 +891,7 @@ open class GestureHandler {
899891
}
900892

901893
private fun setPointerType(event: MotionEvent) {
902-
val pointerIndex = event.actionIndex
903-
904-
pointerType = when (event.getToolType(pointerIndex)) {
905-
MotionEvent.TOOL_TYPE_FINGER -> POINTER_TYPE_TOUCH
906-
MotionEvent.TOOL_TYPE_STYLUS -> POINTER_TYPE_STYLUS
907-
MotionEvent.TOOL_TYPE_MOUSE -> POINTER_TYPE_MOUSE
908-
else -> POINTER_TYPE_OTHER
909-
}
894+
pointerType = event.getPointerType(event.actionIndex)
910895
}
911896

912897
open fun wantsToAttachDirectlyToView() = false

‎packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
package com.swmansion.gesturehandler.react
22

3+
import android.app.Activity
34
import android.content.Context
5+
import android.content.ContextWrapper
46
import android.view.Display
57
import android.view.MotionEvent
68
import android.view.accessibility.AccessibilityManager
79
import com.facebook.react.bridge.ReactContext
810
import com.facebook.react.modules.core.DeviceEventManagerModule
11+
import com.swmansion.gesturehandler.core.GestureHandler
912

1013
val ReactContext.deviceEventEmitter: DeviceEventManagerModule.RCTDeviceEventEmitter
1114
get() = this.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1215

16+
// Views are handed a ThemedReactContext, so the activity may sit behind a chain
17+
// of context wrappers.
18+
fun Context?.findActivity(): Activity? = when (this) {
19+
is ReactContext -> currentActivity
20+
is Activity -> this
21+
is ContextWrapper -> baseContext.findActivity()
22+
else -> null
23+
}
24+
1325
fun Context.isScreenReaderOn() =
1426
(getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager).isTouchExplorationEnabled
1527

@@ -20,6 +32,15 @@ fun MotionEvent.isHoverAction(): Boolean = action == MotionEvent.ACTION_HOVER_MO
2032
fun MotionEvent.isButtonAction(): Boolean = actionMasked == MotionEvent.ACTION_BUTTON_PRESS ||
2133
actionMasked == MotionEvent.ACTION_BUTTON_RELEASE
2234

35+
// Defaults to the first pointer, which is the one every single-pointer stream
36+
// (hover included) describes.
37+
fun MotionEvent.getPointerType(pointerIndex: Int = 0): Int = when (getToolType(pointerIndex)) {
38+
MotionEvent.TOOL_TYPE_FINGER -> GestureHandler.POINTER_TYPE_TOUCH
39+
MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER -> GestureHandler.POINTER_TYPE_STYLUS
40+
MotionEvent.TOOL_TYPE_MOUSE -> GestureHandler.POINTER_TYPE_MOUSE
41+
else -> GestureHandler.POINTER_TYPE_OTHER
42+
}
43+
2344
val Display.minimumFrameTime: Float
2445
get() {
2546
val supportedModes = this.supportedModes

‎packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt‎

Lines changed: 162 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class RNGestureHandlerButtonViewManager :
7979
view.managedHandlerTestID = null
8080
view.managedHandlerHitSlop = null
8181
view.moduleId = null
82+
view.resetHoverState()
8283
// Has to come last — every setter above flags the view as needing a managed handler update.
8384
view.managedHandlerNeedsUpdate = false
8485

@@ -613,14 +614,26 @@ class RNGestureHandlerButtonViewManager :
613614
private var isHovered = false
614615

615616
// Whether a hover was active at press-start. A hovering pointer fires
616-
// ACTION_HOVER_ENTER first (so isHovered is already true at DOWN).
617+
// ACTION_HOVER_ENTER first, so isHovered is already true at DOWN.
617618
private var hoverActiveAtPressStart = false
618619

619-
private val shouldAnimateHover get() = isHovered && isEnabled
620+
// Hover events outlive the MotionEvent behind them (the deferred hover-out,
621+
// and `enabled` flipping while hovered), so the position is copied out.
622+
private var lastHoverSample: HoverSample? = null
620623

621-
private val restingOpacity get() = if (shouldAnimateHover) hoverOpacity else defaultOpacity
622-
private val restingScale get() = if (shouldAnimateHover) hoverScale else defaultScale
623-
private val restingUnderlayOpacity get() = if (shouldAnimateHover) hoverUnderlayOpacity else defaultUnderlayOpacity
624+
// Content view's screen position, resolved once per hover session the way
625+
// GestureHandler.prepare resolves it once per gesture.
626+
private val hoverWindowOffset = IntArray(2)
627+
628+
// The hover state JS was last told about, which drifts from [effectiveHover]
629+
// on purpose — see [dispatchHoverEventIfNeeded] and [onDetachedFromWindow].
630+
private var hoverReported = false
631+
632+
private val effectiveHover get() = isHovered && isEnabled
633+
634+
private val restingOpacity get() = if (effectiveHover) hoverOpacity else defaultOpacity
635+
private val restingScale get() = if (effectiveHover) hoverScale else defaultScale
636+
private val restingUnderlayOpacity get() = if (effectiveHover) hoverUnderlayOpacity else defaultUnderlayOpacity
624637

625638
private val hasOpacityAnimation get() = activeOpacity != 1.0f || defaultOpacity != 1.0f || hoverOpacity != 1.0f
626639
private val hasScaleAnimation get() = activeScale != 1.0f || defaultScale != 1.0f || hoverScale != 1.0f
@@ -858,18 +871,41 @@ class RNGestureHandlerButtonViewManager :
858871
lastAction = action
859872

860873
// No hover events arrive while the button is held, so derive hover from
861-
// the touch stream (within bounds). Gated on hoverActiveAtPressStart so
862-
// it only maintains an already-active hover.
874+
// the touch stream — only ever to maintain one that was already open.
863875
when (event.actionMasked) {
864876
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> hoverActiveAtPressStart = isHovered
865877
MotionEvent.ACTION_MOVE,
866878
MotionEvent.ACTION_UP,
867879
MotionEvent.ACTION_POINTER_UP,
868-
->
880+
-> {
869881
if (hoverActiveAtPressStart) {
870-
isHovered = isWithinBounds(event)
882+
// A touch event describes the pressing pointer, which on a dual-input
883+
// device isn't the hovering one — a finger drag says nothing about a
884+
// mouse or stylus hovering in its own event stream. Only the pointer
885+
// that opened the hover may maintain it.
886+
val pointerType = event.getPointerType()
887+
888+
if (pointerType == lastHoverSample?.pointerType) {
889+
isHovered = isWithinBounds(event)
890+
lastHoverSample = hoverSampleFrom(event, pointerType)
891+
dispatchHoverEventIfNeeded()
892+
}
893+
}
894+
}
895+
// A cancel takes the gesture away from the button for good, and the hover
896+
// stream isn't guaranteed to speak again — hover targets were dropped at
897+
// press-start, so a pointer that ends up anywhere else sends this view
898+
// nothing. The hover has to be closed here or it stays open forever; a
899+
// pointer that is still hovering re-opens it on the next hover-in.
900+
MotionEvent.ACTION_CANCEL -> {
901+
if (hoverActiveAtPressStart && event.getPointerType() == lastHoverSample?.pointerType) {
902+
isHovered = false
903+
recordHoverSample(event)
904+
dispatchHoverEventIfNeeded()
871905
}
872-
MotionEvent.ACTION_CANCEL -> isHovered = false
906+
907+
hoverActiveAtPressStart = false
908+
}
873909
}
874910

875911
val handled = super.onTouchEvent(event)
@@ -904,8 +940,8 @@ class RNGestureHandlerButtonViewManager :
904940

905941
override fun onHoverEvent(event: MotionEvent): Boolean {
906942
when (event.actionMasked) {
907-
MotionEvent.ACTION_HOVER_ENTER -> onHoverIn()
908-
MotionEvent.ACTION_HOVER_EXIT -> onHoverOut()
943+
MotionEvent.ACTION_HOVER_ENTER -> onHoverIn(event)
944+
MotionEvent.ACTION_HOVER_EXIT -> onHoverOut(event)
909945
}
910946

911947
return super.onHoverEvent(event)
@@ -1000,37 +1036,51 @@ class RNGestureHandlerButtonViewManager :
10001036
return
10011037
}
10021038

1003-
if (shouldAnimateHover) {
1039+
if (effectiveHover) {
10041040
animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong())
10051041
} else {
10061042
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, hoverAnimationOutDuration.toLong())
10071043
}
10081044
}
10091045

1010-
private fun onHoverIn() {
1046+
private fun onHoverIn(event: MotionEvent) {
10111047
cancelPendingHoverOut()
10121048

10131049
if (isHovered) {
10141050
return
10151051
}
10161052

10171053
isHovered = true
1054+
// Ahead of the first sample — hoverSampleFrom converts with this offset.
1055+
captureHoverWindowOffset()
1056+
recordHoverSample(event)
1057+
dispatchHoverEventIfNeeded()
10181058
animateHoverState()
10191059
}
10201060

1021-
private fun onHoverOut() {
1061+
private fun onHoverOut(event: MotionEvent) {
10221062
if (isPressed) {
10231063
isHovered = false
1064+
// The hover is genuinely over, so stop deriving it — otherwise the next
1065+
// ACTION_MOVE re-opens it for the pressing pointer.
1066+
hoverActiveAtPressStart = false
1067+
recordHoverSample(event)
1068+
dispatchHoverEventIfNeeded()
10241069
return
10251070
}
10261071

10271072
cancelPendingHoverOut()
10281073

10291074
// Hover-out arrives just before a press-down, so defer a frame to let a
10301075
// following press-in cancel it and keep the hover state through the press.
1076+
// The JS event goes out from the callback too, so a cancelled hover-out
1077+
// never reaches JS.
1078+
val sample = hoverSampleFrom(event, event.getPointerType())
10311079
val callback = Choreographer.FrameCallback {
10321080
pendingHoverOut = null
10331081
isHovered = false
1082+
lastHoverSample = sample
1083+
dispatchHoverEventIfNeeded()
10341084
animateHoverState()
10351085
}
10361086

@@ -1043,6 +1093,81 @@ class RNGestureHandlerButtonViewManager :
10431093
pendingHoverOut = null
10441094
}
10451095

1096+
private fun hoverSampleFrom(event: MotionEvent, pointerType: Int) = HoverSample(
1097+
x = event.x,
1098+
y = event.y,
1099+
absoluteX = event.rawX - hoverWindowOffset[0],
1100+
absoluteY = event.rawY - hoverWindowOffset[1],
1101+
pointerType = pointerType,
1102+
pointerInside = isPointerInside(event.x, event.y),
1103+
)
1104+
1105+
// Asked of the handler so `pointerInside` means the same hitSlop-expanded
1106+
// rect press events report it from. A sample taken without a handler is
1107+
// never dispatched, so the fallback is arbitrary.
1108+
private fun isPointerInside(x: Float, y: Float): Boolean {
1109+
val moduleId = moduleId ?: return false
1110+
val handlerTag = managedHandlerTag ?: return false
1111+
val handler = RNGestureHandlerModule.registries[moduleId]?.getHandler(handlerTag) ?: return false
1112+
1113+
return handler.isWithinBounds(this, x, y)
1114+
}
1115+
1116+
// No content view leaves screen coordinates as the best available answer,
1117+
// the same fallback GestureHandler.prepare takes.
1118+
private fun captureHoverWindowOffset() {
1119+
val content = context.findActivity()?.findViewById<View>(android.R.id.content)
1120+
if (content != null) {
1121+
content.getLocationOnScreen(hoverWindowOffset)
1122+
} else {
1123+
hoverWindowOffset[0] = 0
1124+
hoverWindowOffset[1] = 0
1125+
}
1126+
}
1127+
1128+
private fun recordHoverSample(event: MotionEvent) {
1129+
lastHoverSample = hoverSampleFrom(event, event.getPointerType())
1130+
}
1131+
1132+
fun resetHoverState() {
1133+
isHovered = false
1134+
hoverReported = false
1135+
hoverActiveAtPressStart = false
1136+
lastHoverSample = null
1137+
}
1138+
1139+
/**
1140+
* Emits the balancing hover event whenever [hoverReported] drifts from
1141+
* [effectiveHover]. Sharing that property with the hover visual is what
1142+
* keeps callbacks and appearance in step, so disabling a hovered button
1143+
* reports a hover-out and re-enabling it reports a hover-in.
1144+
*/
1145+
private fun dispatchHoverEventIfNeeded() {
1146+
// Only the v3 managed button listens for hover events. Checked before
1147+
// `hoverReported` is touched, so a tag attached midway through a hover
1148+
// can't leave it claiming a hover-in JS never received.
1149+
if (managedHandlerTag == null) {
1150+
return
1151+
}
1152+
1153+
val effective = effectiveHover
1154+
1155+
if (effective == hoverReported) {
1156+
return
1157+
}
1158+
1159+
hoverReported = effective
1160+
dispatchHoverEvent(if (effective) EventType.HoverIn else EventType.HoverOut)
1161+
}
1162+
1163+
private fun dispatchHoverEvent(type: EventType) {
1164+
val sample = lastHoverSample ?: return
1165+
val reactContext = context as? ReactContext ?: return
1166+
val eventDispatcher = UIManagerHelper.getEventDispatcher(reactContext) ?: return
1167+
1168+
eventDispatcher.dispatchEvent(RNGestureHandlerButtonEvent.obtain(this, sample, type))
1169+
}
1170+
10461171
private fun isWithinBounds(event: MotionEvent): Boolean =
10471172
event.x >= 0 && event.y >= 0 && event.x < width && event.y < height
10481173

@@ -1206,6 +1331,9 @@ class RNGestureHandlerButtonViewManager :
12061331
cancelPendingHoverOut()
12071332
currentAnimator?.cancel()
12081333
currentAnimator = null
1334+
// `hoverReported` is deliberately left alone: Fabric reparents by removing
1335+
// and re-inserting, so detaching is not proof the pointer left. A genuine
1336+
// teardown clears it in `onDropViewInstance` instead.
12091337
isHovered = false
12101338
applyStartAnimationState()
12111339

@@ -1348,6 +1476,8 @@ class RNGestureHandlerButtonViewManager :
13481476
// The managed handler mirrors the button's enabled state.
13491477
managedHandlerNeedsUpdate = true
13501478

1479+
dispatchHoverEventIfNeeded()
1480+
13511481
if (isHovered) {
13521482
animateHoverState()
13531483
}
@@ -1403,11 +1533,28 @@ class RNGestureHandlerButtonViewManager :
14031533
}
14041534
}
14051535

1536+
/**
1537+
* Snapshot of the hovering pointer, in pixels (events convert to DIP).
1538+
* [x]/[y] are relative to the button, [absoluteX]/[absoluteY] to the window
1539+
* as it sat when the hover session opened — a window moved mid-hover leaves
1540+
* them stale until the pointer leaves and comes back.
1541+
*/
1542+
data class HoverSample(
1543+
val x: Float,
1544+
val y: Float,
1545+
val absoluteX: Float,
1546+
val absoluteY: Float,
1547+
val pointerType: Int,
1548+
val pointerInside: Boolean,
1549+
)
1550+
14061551
enum class EventType {
14071552
Press,
14081553
PressIn,
14091554
PressOut,
14101555
LongPress,
1556+
HoverIn,
1557+
HoverOut,
14111558
InteractionFinished,
14121559
}
14131560
}

0 commit comments

Comments
 (0)