Skip to content

Commit 16a266e

Browse files
authored
Add Hover gesture (#2455)
## Description Introduce a new gesture: `Hover`. It works exactly how you would expect based on the name 😄. It supports hovering with a mouse & stylus on all platforms and makes it easy to add [Pointer interactions](https://developer.apple.com/documentation/uikit/pointer_interactions) to a view on iOS (via `withFeedback` method). The API is identical to all other gestures, you can simply create the configuration object and set the callbacks: ```jsx const gesture = Gesture.Hover() .onBegin(() => { console.log('hover begin'); }) .onFinalize(() => { console.log('hover finalize'); }) ``` ## Test plan This PR adds two examples: `Hover` and `HoverableIcons`, which can be used to verify that the gesture works correctly quickly.
1 parent 04ed17e commit 16a266e

35 files changed

+885
-46
lines changed

android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,14 @@ open class GestureHandler<ConcreteGestureHandlerT : GestureHandler<ConcreteGestu
371371
lastAbsolutePositionY = GestureUtils.getLastPointerY(adaptedTransformedEvent, true)
372372
lastEventOffsetX = adaptedTransformedEvent.rawX - adaptedTransformedEvent.x
373373
lastEventOffsetY = adaptedTransformedEvent.rawY - adaptedTransformedEvent.y
374-
onHandle(adaptedTransformedEvent, adaptedSourceEvent)
374+
if (sourceEvent.action == MotionEvent.ACTION_HOVER_ENTER ||
375+
sourceEvent.action == MotionEvent.ACTION_HOVER_MOVE ||
376+
sourceEvent.action == MotionEvent.ACTION_HOVER_EXIT
377+
) {
378+
onHandleHover(adaptedTransformedEvent, adaptedSourceEvent)
379+
} else {
380+
onHandle(adaptedTransformedEvent, adaptedSourceEvent)
381+
}
375382
if (adaptedTransformedEvent != transformedEvent) {
376383
adaptedTransformedEvent.recycle()
377384
}
@@ -675,6 +682,8 @@ open class GestureHandler<ConcreteGestureHandlerT : GestureHandler<ConcreteGestu
675682
moveToState(STATE_FAILED)
676683
}
677684

685+
protected open fun onHandleHover(event: MotionEvent, sourceEvent: MotionEvent) {}
686+
678687
protected open fun onStateChange(newState: Int, previousState: Int) {}
679688
protected open fun onReset() {}
680689
protected open fun onCancel() {}

android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class GestureHandlerOrchestrator(
3737
fun onTouchEvent(event: MotionEvent): Boolean {
3838
isHandlingTouch = true
3939
val action = event.actionMasked
40-
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) {
40+
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN || action == MotionEvent.ACTION_HOVER_MOVE) {
4141
extractGestureHandlers(event)
4242
} else if (action == MotionEvent.ACTION_CANCEL) {
4343
cancelAll()
@@ -295,7 +295,7 @@ class GestureHandlerOrchestrator(
295295

296296
// if event was of type UP or POINTER_UP we request handler to stop tracking now that
297297
// the event has been dispatched
298-
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) {
298+
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_HOVER_EXIT) {
299299
val pointerId = event.getPointerId(event.actionIndex)
300300
handler.stopTrackingPointer(pointerId)
301301
}
@@ -464,16 +464,24 @@ class GestureHandlerOrchestrator(
464464
return found
465465
}
466466

467-
private fun recordViewHandlersForPointer(view: View, coords: FloatArray, pointerId: Int): Boolean {
467+
private fun recordViewHandlersForPointer(view: View, coords: FloatArray, pointerId: Int, event: MotionEvent): Boolean {
468468
var found = false
469469
handlerRegistry.getHandlersForView(view)?.let {
470470
synchronized(it) {
471471
for (handler in it) {
472-
if (handler.isEnabled && handler.isWithinBounds(view, coords[0], coords[1])) {
473-
recordHandlerIfNotPresent(handler, view)
474-
handler.startTrackingPointer(pointerId)
475-
found = true
472+
// skip disabled and out-of-bounds handlers
473+
if (!handler.isEnabled || !handler.isWithinBounds(view, coords[0], coords[1])) {
474+
continue
476475
}
476+
477+
// we don't want to extract gestures other than hover when processing hover events
478+
if (event.action in listOf(MotionEvent.ACTION_HOVER_EXIT, MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE) && handler !is HoverGestureHandler) {
479+
continue
480+
}
481+
482+
recordHandlerIfNotPresent(handler, view)
483+
handler.startTrackingPointer(pointerId)
484+
found = true
477485
}
478486
}
479487
}
@@ -494,11 +502,11 @@ class GestureHandlerOrchestrator(
494502
val pointerId = event.getPointerId(actionIndex)
495503
tempCoords[0] = event.getX(actionIndex)
496504
tempCoords[1] = event.getY(actionIndex)
497-
traverseWithPointerEvents(wrapperView, tempCoords, pointerId)
498-
extractGestureHandlers(wrapperView, tempCoords, pointerId)
505+
traverseWithPointerEvents(wrapperView, tempCoords, pointerId, event)
506+
extractGestureHandlers(wrapperView, tempCoords, pointerId, event)
499507
}
500508

501-
private fun extractGestureHandlers(viewGroup: ViewGroup, coords: FloatArray, pointerId: Int): Boolean {
509+
private fun extractGestureHandlers(viewGroup: ViewGroup, coords: FloatArray, pointerId: Int, event: MotionEvent): Boolean {
502510
val childrenCount = viewGroup.childCount
503511
for (i in childrenCount - 1 downTo 0) {
504512
val child = viewConfigHelper.getChildInDrawingOrderAtIndex(viewGroup, i)
@@ -513,7 +521,7 @@ class GestureHandlerOrchestrator(
513521
if (!isClipping(child) || isTransformedTouchPointInView(coords[0], coords[1], child)) {
514522
// we only consider the view if touch is inside the view bounds or if the view's children
515523
// can render outside of the view bounds (overflow visible)
516-
found = traverseWithPointerEvents(child, coords, pointerId)
524+
found = traverseWithPointerEvents(child, coords, pointerId, event)
517525
}
518526
coords[0] = restoreX
519527
coords[1] = restoreY
@@ -525,7 +533,7 @@ class GestureHandlerOrchestrator(
525533
return false
526534
}
527535

528-
private fun traverseWithPointerEvents(view: View, coords: FloatArray, pointerId: Int): Boolean =
536+
private fun traverseWithPointerEvents(view: View, coords: FloatArray, pointerId: Int, event: MotionEvent): Boolean =
529537
when (viewConfigHelper.getPointerEventsConfigForView(view)) {
530538
PointerEventsConfig.NONE -> {
531539
// This view and its children can't be the target
@@ -534,18 +542,18 @@ class GestureHandlerOrchestrator(
534542
PointerEventsConfig.BOX_ONLY -> {
535543
// This view is the target, its children don't matter
536544
(
537-
recordViewHandlersForPointer(view, coords, pointerId) ||
545+
recordViewHandlersForPointer(view, coords, pointerId, event) ||
538546
shouldHandlerlessViewBecomeTouchTarget(view, coords)
539547
)
540548
}
541549
PointerEventsConfig.BOX_NONE -> {
542550
// This view can't be the target, but its children might
543551
when (view) {
544552
is ViewGroup -> {
545-
extractGestureHandlers(view, coords, pointerId).also { found ->
553+
extractGestureHandlers(view, coords, pointerId, event).also { found ->
546554
// A child view is handling touch, also extract handlers attached to this view
547555
if (found) {
548-
recordViewHandlersForPointer(view, coords, pointerId)
556+
recordViewHandlersForPointer(view, coords, pointerId, event)
549557
}
550558
}
551559
}
@@ -554,19 +562,19 @@ class GestureHandlerOrchestrator(
554562
// handlers attached to the text input, as it makes sense that gestures would work on a
555563
// non-editable TextInput.
556564
is EditText -> {
557-
recordViewHandlersForPointer(view, coords, pointerId)
565+
recordViewHandlersForPointer(view, coords, pointerId, event)
558566
}
559567
else -> false
560568
}
561569
}
562570
PointerEventsConfig.AUTO -> {
563571
// Either this view or one of its children is the target
564572
val found = if (view is ViewGroup) {
565-
extractGestureHandlers(view, coords, pointerId)
573+
extractGestureHandlers(view, coords, pointerId, event)
566574
} else false
567575

568576
(
569-
recordViewHandlersForPointer(view, coords, pointerId) ||
577+
recordViewHandlersForPointer(view, coords, pointerId, event) ||
570578
found || shouldHandlerlessViewBecomeTouchTarget(view, coords)
571579
)
572580
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package com.swmansion.gesturehandler.core
2+
3+
import android.os.Handler
4+
import android.os.Looper
5+
import android.view.MotionEvent
6+
import android.view.View
7+
import android.view.ViewGroup
8+
import com.swmansion.gesturehandler.react.RNViewConfigurationHelper
9+
10+
class HoverGestureHandler : GestureHandler<HoverGestureHandler>() {
11+
private var handler: Handler? = null
12+
private var finishRunnable = Runnable { finish() }
13+
14+
private infix fun isAncestorOf(other: GestureHandler<*>): Boolean {
15+
var current: View? = other.view
16+
17+
while (current != null) {
18+
if (current == this.view) {
19+
return true
20+
}
21+
22+
current = current.parent as? View
23+
}
24+
25+
return false
26+
}
27+
28+
private fun isViewDisplayedOverAnother(view: View, other: View, rootView: View = view.rootView): Boolean? {
29+
// traverse the tree starting on the root view, to see which view will be drawn first
30+
if (rootView == other) {
31+
return true
32+
}
33+
34+
if (rootView == view) {
35+
return false
36+
}
37+
38+
if (rootView is ViewGroup) {
39+
for (i in 0 until rootView.childCount) {
40+
val child = viewConfigHelper.getChildInDrawingOrderAtIndex(rootView, i)
41+
return isViewDisplayedOverAnother(view, other, child) ?: continue
42+
}
43+
}
44+
45+
return null
46+
}
47+
48+
override fun shouldBeCancelledBy(handler: GestureHandler<*>): Boolean {
49+
if (handler is HoverGestureHandler && !(handler isAncestorOf this)) {
50+
return isViewDisplayedOverAnother(handler.view!!, this.view!!)!!
51+
}
52+
53+
return super.shouldBeCancelledBy(handler)
54+
}
55+
56+
override fun shouldRequireToWaitForFailure(handler: GestureHandler<*>): Boolean {
57+
if (handler is HoverGestureHandler) {
58+
if (!(this isAncestorOf handler) && !(handler isAncestorOf this)) {
59+
isViewDisplayedOverAnother(this.view!!, handler.view!!)?.let {
60+
return it
61+
}
62+
}
63+
}
64+
65+
return super.shouldRequireToWaitForFailure(handler)
66+
}
67+
68+
override fun shouldRecognizeSimultaneously(handler: GestureHandler<*>): Boolean {
69+
if (handler is HoverGestureHandler && (this isAncestorOf handler || handler isAncestorOf this)) {
70+
return true
71+
}
72+
73+
return super.shouldRecognizeSimultaneously(handler)
74+
}
75+
76+
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
77+
if (event.action == MotionEvent.ACTION_DOWN) {
78+
handler?.removeCallbacksAndMessages(null)
79+
handler = null
80+
} else if (event.action == MotionEvent.ACTION_UP) {
81+
if (!isWithinBounds) {
82+
finish()
83+
}
84+
}
85+
}
86+
87+
override fun onHandleHover(event: MotionEvent, sourceEvent: MotionEvent) {
88+
when {
89+
event.action == MotionEvent.ACTION_HOVER_EXIT -> {
90+
if (handler == null) {
91+
handler = Handler(Looper.getMainLooper())
92+
}
93+
94+
handler!!.postDelayed(finishRunnable, 4)
95+
}
96+
97+
!isWithinBounds -> {
98+
finish()
99+
}
100+
101+
this.state == STATE_UNDETERMINED &&
102+
(event.action == MotionEvent.ACTION_HOVER_MOVE || event.action == MotionEvent.ACTION_HOVER_ENTER) -> {
103+
begin()
104+
activate()
105+
}
106+
}
107+
}
108+
109+
private fun finish() {
110+
when (this.state) {
111+
STATE_UNDETERMINED -> cancel()
112+
STATE_BEGAN -> fail()
113+
STATE_ACTIVE -> end()
114+
}
115+
}
116+
117+
companion object {
118+
private val viewConfigHelper = RNViewConfigurationHelper()
119+
}
120+
}

android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.swmansion.gesturehandler.BuildConfig
2020
import com.swmansion.gesturehandler.ReanimatedEventDispatcher
2121
import com.swmansion.gesturehandler.core.FlingGestureHandler
2222
import com.swmansion.gesturehandler.core.GestureHandler
23+
import com.swmansion.gesturehandler.core.HoverGestureHandler
2324
import com.swmansion.gesturehandler.core.LongPressGestureHandler
2425
import com.swmansion.gesturehandler.core.ManualGestureHandler
2526
import com.swmansion.gesturehandler.core.NativeViewGestureHandler
@@ -337,6 +338,25 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) :
337338
}
338339
}
339340

341+
private class HoverGestureHandlerFactory : HandlerFactory<HoverGestureHandler>() {
342+
override val type = HoverGestureHandler::class.java
343+
override val name = "HoverGestureHandler"
344+
345+
override fun create(context: Context?): HoverGestureHandler {
346+
return HoverGestureHandler()
347+
}
348+
349+
override fun extractEventData(handler: HoverGestureHandler, eventData: WritableMap) {
350+
super.extractEventData(handler, eventData)
351+
with(eventData) {
352+
putDouble("x", PixelUtil.toDIPFromPixel(handler.lastRelativePositionX).toDouble())
353+
putDouble("y", PixelUtil.toDIPFromPixel(handler.lastRelativePositionY).toDouble())
354+
putDouble("absoluteX", PixelUtil.toDIPFromPixel(handler.lastPositionInWindowX).toDouble())
355+
putDouble("absoluteY", PixelUtil.toDIPFromPixel(handler.lastPositionInWindowY).toDouble())
356+
}
357+
}
358+
}
359+
340360
private val eventListener = object : OnTouchEventListener {
341361
override fun <T : GestureHandler<T>> onHandlerUpdate(handler: T, event: MotionEvent) {
342362
this@RNGestureHandlerModule.onHandlerUpdate(handler)
@@ -359,6 +379,7 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) :
359379
RotationGestureHandlerFactory(),
360380
FlingGestureHandlerFactory(),
361381
ManualGestureHandlerFactory(),
382+
HoverGestureHandlerFactory(),
362383
)
363384
val registry: RNGestureHandlerRegistry = RNGestureHandlerRegistry()
364385
private val interactionManager = RNGestureHandlerInteractionManager()

android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) {
3737
true
3838
} else super.dispatchTouchEvent(ev)
3939

40+
override fun dispatchGenericMotionEvent(event: MotionEvent) =
41+
if (_enabled && rootHelper!!.dispatchTouchEvent(event)) {
42+
true
43+
} else super.dispatchTouchEvent(event)
44+
4045
override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
4146
if (_enabled) {
4247
rootHelper!!.requestDisallowInterceptTouchEvent(disallowIntercept)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
id: hover-gesture
3+
title: Hover gesture
4+
sidebar_label: Hover gesture
5+
---
6+
7+
import BaseEventData from './base-gesture-event-data.md';
8+
import BaseEventConfig from './base-gesture-config.md';
9+
import BaseEventCallbacks from './base-gesture-callbacks.md';
10+
import BaseContinousEventCallbacks from './base-continous-gesture-callbacks.md';
11+
12+
A continuous gesture that can recognize hovering above the view it's attached to. The hover effect may be activated by moving a mouse or a stylus over the view.
13+
14+
On iOS additional visual effects may be configured.
15+
16+
:::info
17+
Don't rely on `Hover` gesture to continue after the mouse button is clicked or the stylus touches the screen. If you want to handle both cases, [compose](../../gesture-composition.md) it with [`Pan` gesture](./pan-gesture.md).
18+
:::
19+
20+
## Config
21+
22+
### Properties specific to `HoverGesture`:
23+
24+
### `effect(effect: HoverEffect)` (iOS only)
25+
26+
Visual effect applied to the view while the view is hovered. The possible values are:
27+
28+
- `HoverEffect.None`
29+
- `HoverEffect.Lift`
30+
- `HoverEffect.Highlight`
31+
32+
Defaults to `HoverEffect.None`
33+
34+
<BaseEventConfig />
35+
36+
## Callbacks
37+
38+
<BaseEventCallbacks />
39+
<BaseContinousEventCallbacks />
40+
41+
## Event data
42+
43+
### Event attributes specific to `HoverGesture`:
44+
45+
### `x`
46+
47+
X coordinate of the current position of the pointer relative to the view attached to the [`GestureDetector`](./gesture-detector.md). Expressed in point units.
48+
49+
### `y`
50+
51+
Y coordinate of the current position of the pointer relative to the view attached to the [`GestureDetector`](./gesture-detector.md). Expressed in point units.
52+
53+
### `absoluteX`
54+
55+
X coordinate of the current position of the pointer relative to the window. The value is expressed in point units. It is recommended to use it instead of [`x`](#x) in cases when the original view can be transformed as an effect of the gesture.
56+
57+
### `absoluteY`
58+
59+
Y coordinate of the current position of the pointer relative to the window. The value is expressed in point units. It is recommended to use it instead of [`y`](#y) in cases when the original view can be transformed as an effect of the gesture.
60+
61+
<BaseEventData />

docs/sidebars.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ module.exports = {
3737
'api/gestures/force-touch-gesture',
3838
'api/gestures/native-gesture',
3939
'api/gestures/manual-gesture',
40+
'api/gestures/hover-gesture',
4041
'api/gestures/composed-gestures',
4142
'api/gestures/touch-events',
4243
'api/gestures/state-manager',

0 commit comments

Comments
 (0)