Skip to content

Commit

Permalink
Add Hover gesture (#2455)
Browse files Browse the repository at this point in the history
## 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.
  • Loading branch information
j-piasecki authored Aug 10, 2023
1 parent 04ed17e commit 16a266e
Show file tree
Hide file tree
Showing 35 changed files with 885 additions and 46 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,14 @@ open class GestureHandler<ConcreteGestureHandlerT : GestureHandler<ConcreteGestu
lastAbsolutePositionY = GestureUtils.getLastPointerY(adaptedTransformedEvent, true)
lastEventOffsetX = adaptedTransformedEvent.rawX - adaptedTransformedEvent.x
lastEventOffsetY = adaptedTransformedEvent.rawY - adaptedTransformedEvent.y
onHandle(adaptedTransformedEvent, adaptedSourceEvent)
if (sourceEvent.action == MotionEvent.ACTION_HOVER_ENTER ||
sourceEvent.action == MotionEvent.ACTION_HOVER_MOVE ||
sourceEvent.action == MotionEvent.ACTION_HOVER_EXIT
) {
onHandleHover(adaptedTransformedEvent, adaptedSourceEvent)
} else {
onHandle(adaptedTransformedEvent, adaptedSourceEvent)
}
if (adaptedTransformedEvent != transformedEvent) {
adaptedTransformedEvent.recycle()
}
Expand Down Expand Up @@ -675,6 +682,8 @@ open class GestureHandler<ConcreteGestureHandlerT : GestureHandler<ConcreteGestu
moveToState(STATE_FAILED)
}

protected open fun onHandleHover(event: MotionEvent, sourceEvent: MotionEvent) {}

protected open fun onStateChange(newState: Int, previousState: Int) {}
protected open fun onReset() {}
protected open fun onCancel() {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class GestureHandlerOrchestrator(
fun onTouchEvent(event: MotionEvent): Boolean {
isHandlingTouch = true
val action = event.actionMasked
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) {
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN || action == MotionEvent.ACTION_HOVER_MOVE) {
extractGestureHandlers(event)
} else if (action == MotionEvent.ACTION_CANCEL) {
cancelAll()
Expand Down Expand Up @@ -295,7 +295,7 @@ class GestureHandlerOrchestrator(

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

private fun recordViewHandlersForPointer(view: View, coords: FloatArray, pointerId: Int): Boolean {
private fun recordViewHandlersForPointer(view: View, coords: FloatArray, pointerId: Int, event: MotionEvent): Boolean {
var found = false
handlerRegistry.getHandlersForView(view)?.let {
synchronized(it) {
for (handler in it) {
if (handler.isEnabled && handler.isWithinBounds(view, coords[0], coords[1])) {
recordHandlerIfNotPresent(handler, view)
handler.startTrackingPointer(pointerId)
found = true
// skip disabled and out-of-bounds handlers
if (!handler.isEnabled || !handler.isWithinBounds(view, coords[0], coords[1])) {
continue
}

// we don't want to extract gestures other than hover when processing hover events
if (event.action in listOf(MotionEvent.ACTION_HOVER_EXIT, MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE) && handler !is HoverGestureHandler) {
continue
}

recordHandlerIfNotPresent(handler, view)
handler.startTrackingPointer(pointerId)
found = true
}
}
}
Expand All @@ -494,11 +502,11 @@ class GestureHandlerOrchestrator(
val pointerId = event.getPointerId(actionIndex)
tempCoords[0] = event.getX(actionIndex)
tempCoords[1] = event.getY(actionIndex)
traverseWithPointerEvents(wrapperView, tempCoords, pointerId)
extractGestureHandlers(wrapperView, tempCoords, pointerId)
traverseWithPointerEvents(wrapperView, tempCoords, pointerId, event)
extractGestureHandlers(wrapperView, tempCoords, pointerId, event)
}

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

private fun traverseWithPointerEvents(view: View, coords: FloatArray, pointerId: Int): Boolean =
private fun traverseWithPointerEvents(view: View, coords: FloatArray, pointerId: Int, event: MotionEvent): Boolean =
when (viewConfigHelper.getPointerEventsConfigForView(view)) {
PointerEventsConfig.NONE -> {
// This view and its children can't be the target
Expand All @@ -534,18 +542,18 @@ class GestureHandlerOrchestrator(
PointerEventsConfig.BOX_ONLY -> {
// This view is the target, its children don't matter
(
recordViewHandlersForPointer(view, coords, pointerId) ||
recordViewHandlersForPointer(view, coords, pointerId, event) ||
shouldHandlerlessViewBecomeTouchTarget(view, coords)
)
}
PointerEventsConfig.BOX_NONE -> {
// This view can't be the target, but its children might
when (view) {
is ViewGroup -> {
extractGestureHandlers(view, coords, pointerId).also { found ->
extractGestureHandlers(view, coords, pointerId, event).also { found ->
// A child view is handling touch, also extract handlers attached to this view
if (found) {
recordViewHandlersForPointer(view, coords, pointerId)
recordViewHandlersForPointer(view, coords, pointerId, event)
}
}
}
Expand All @@ -554,19 +562,19 @@ class GestureHandlerOrchestrator(
// handlers attached to the text input, as it makes sense that gestures would work on a
// non-editable TextInput.
is EditText -> {
recordViewHandlersForPointer(view, coords, pointerId)
recordViewHandlersForPointer(view, coords, pointerId, event)
}
else -> false
}
}
PointerEventsConfig.AUTO -> {
// Either this view or one of its children is the target
val found = if (view is ViewGroup) {
extractGestureHandlers(view, coords, pointerId)
extractGestureHandlers(view, coords, pointerId, event)
} else false

(
recordViewHandlersForPointer(view, coords, pointerId) ||
recordViewHandlersForPointer(view, coords, pointerId, event) ||
found || shouldHandlerlessViewBecomeTouchTarget(view, coords)
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.swmansion.gesturehandler.core

import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import com.swmansion.gesturehandler.react.RNViewConfigurationHelper

class HoverGestureHandler : GestureHandler<HoverGestureHandler>() {
private var handler: Handler? = null
private var finishRunnable = Runnable { finish() }

private infix fun isAncestorOf(other: GestureHandler<*>): Boolean {
var current: View? = other.view

while (current != null) {
if (current == this.view) {
return true
}

current = current.parent as? View
}

return false
}

private fun isViewDisplayedOverAnother(view: View, other: View, rootView: View = view.rootView): Boolean? {
// traverse the tree starting on the root view, to see which view will be drawn first
if (rootView == other) {
return true
}

if (rootView == view) {
return false
}

if (rootView is ViewGroup) {
for (i in 0 until rootView.childCount) {
val child = viewConfigHelper.getChildInDrawingOrderAtIndex(rootView, i)
return isViewDisplayedOverAnother(view, other, child) ?: continue
}
}

return null
}

override fun shouldBeCancelledBy(handler: GestureHandler<*>): Boolean {
if (handler is HoverGestureHandler && !(handler isAncestorOf this)) {
return isViewDisplayedOverAnother(handler.view!!, this.view!!)!!
}

return super.shouldBeCancelledBy(handler)
}

override fun shouldRequireToWaitForFailure(handler: GestureHandler<*>): Boolean {
if (handler is HoverGestureHandler) {
if (!(this isAncestorOf handler) && !(handler isAncestorOf this)) {
isViewDisplayedOverAnother(this.view!!, handler.view!!)?.let {
return it
}
}
}

return super.shouldRequireToWaitForFailure(handler)
}

override fun shouldRecognizeSimultaneously(handler: GestureHandler<*>): Boolean {
if (handler is HoverGestureHandler && (this isAncestorOf handler || handler isAncestorOf this)) {
return true
}

return super.shouldRecognizeSimultaneously(handler)
}

override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
if (event.action == MotionEvent.ACTION_DOWN) {
handler?.removeCallbacksAndMessages(null)
handler = null
} else if (event.action == MotionEvent.ACTION_UP) {
if (!isWithinBounds) {
finish()
}
}
}

override fun onHandleHover(event: MotionEvent, sourceEvent: MotionEvent) {
when {
event.action == MotionEvent.ACTION_HOVER_EXIT -> {
if (handler == null) {
handler = Handler(Looper.getMainLooper())
}

handler!!.postDelayed(finishRunnable, 4)
}

!isWithinBounds -> {
finish()
}

this.state == STATE_UNDETERMINED &&
(event.action == MotionEvent.ACTION_HOVER_MOVE || event.action == MotionEvent.ACTION_HOVER_ENTER) -> {
begin()
activate()
}
}
}

private fun finish() {
when (this.state) {
STATE_UNDETERMINED -> cancel()
STATE_BEGAN -> fail()
STATE_ACTIVE -> end()
}
}

companion object {
private val viewConfigHelper = RNViewConfigurationHelper()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.swmansion.gesturehandler.BuildConfig
import com.swmansion.gesturehandler.ReanimatedEventDispatcher
import com.swmansion.gesturehandler.core.FlingGestureHandler
import com.swmansion.gesturehandler.core.GestureHandler
import com.swmansion.gesturehandler.core.HoverGestureHandler
import com.swmansion.gesturehandler.core.LongPressGestureHandler
import com.swmansion.gesturehandler.core.ManualGestureHandler
import com.swmansion.gesturehandler.core.NativeViewGestureHandler
Expand Down Expand Up @@ -337,6 +338,25 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) :
}
}

private class HoverGestureHandlerFactory : HandlerFactory<HoverGestureHandler>() {
override val type = HoverGestureHandler::class.java
override val name = "HoverGestureHandler"

override fun create(context: Context?): HoverGestureHandler {
return HoverGestureHandler()
}

override fun extractEventData(handler: HoverGestureHandler, eventData: WritableMap) {
super.extractEventData(handler, eventData)
with(eventData) {
putDouble("x", PixelUtil.toDIPFromPixel(handler.lastRelativePositionX).toDouble())
putDouble("y", PixelUtil.toDIPFromPixel(handler.lastRelativePositionY).toDouble())
putDouble("absoluteX", PixelUtil.toDIPFromPixel(handler.lastPositionInWindowX).toDouble())
putDouble("absoluteY", PixelUtil.toDIPFromPixel(handler.lastPositionInWindowY).toDouble())
}
}
}

private val eventListener = object : OnTouchEventListener {
override fun <T : GestureHandler<T>> onHandlerUpdate(handler: T, event: MotionEvent) {
this@RNGestureHandlerModule.onHandlerUpdate(handler)
Expand All @@ -359,6 +379,7 @@ class RNGestureHandlerModule(reactContext: ReactApplicationContext?) :
RotationGestureHandlerFactory(),
FlingGestureHandlerFactory(),
ManualGestureHandlerFactory(),
HoverGestureHandlerFactory(),
)
val registry: RNGestureHandlerRegistry = RNGestureHandlerRegistry()
private val interactionManager = RNGestureHandlerInteractionManager()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) {
true
} else super.dispatchTouchEvent(ev)

override fun dispatchGenericMotionEvent(event: MotionEvent) =
if (_enabled && rootHelper!!.dispatchTouchEvent(event)) {
true
} else super.dispatchTouchEvent(event)

override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
if (_enabled) {
rootHelper!!.requestDisallowInterceptTouchEvent(disallowIntercept)
Expand Down
61 changes: 61 additions & 0 deletions docs/docs/api/gestures/hover-gesture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
id: hover-gesture
title: Hover gesture
sidebar_label: Hover gesture
---

import BaseEventData from './base-gesture-event-data.md';
import BaseEventConfig from './base-gesture-config.md';
import BaseEventCallbacks from './base-gesture-callbacks.md';
import BaseContinousEventCallbacks from './base-continous-gesture-callbacks.md';

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.

On iOS additional visual effects may be configured.

:::info
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).
:::

## Config

### Properties specific to `HoverGesture`:

### `effect(effect: HoverEffect)` (iOS only)

Visual effect applied to the view while the view is hovered. The possible values are:

- `HoverEffect.None`
- `HoverEffect.Lift`
- `HoverEffect.Highlight`

Defaults to `HoverEffect.None`

<BaseEventConfig />

## Callbacks

<BaseEventCallbacks />
<BaseContinousEventCallbacks />

## Event data

### Event attributes specific to `HoverGesture`:

### `x`

X coordinate of the current position of the pointer relative to the view attached to the [`GestureDetector`](./gesture-detector.md). Expressed in point units.

### `y`

Y coordinate of the current position of the pointer relative to the view attached to the [`GestureDetector`](./gesture-detector.md). Expressed in point units.

### `absoluteX`

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.

### `absoluteY`

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.

<BaseEventData />
1 change: 1 addition & 0 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ module.exports = {
'api/gestures/force-touch-gesture',
'api/gestures/native-gesture',
'api/gestures/manual-gesture',
'api/gestures/hover-gesture',
'api/gestures/composed-gestures',
'api/gestures/touch-events',
'api/gestures/state-manager',
Expand Down
Loading

0 comments on commit 16a266e

Please sign in to comment.