Skip to content

Commit bf6fed5

Browse files
committed
Match mobile-web :hover behavior on touch
Touch :hover now follows mobile Chromium: a tap makes the touched view sticky-hovered, while scrolling or dragging the finger over it no longer clears it. Hover clears only on a tap elsewhere or on blank space, or when the finger is released off the originally touched view. Only the first finger drives :hover; extra fingers are ignored until it lifts, so hover is no longer indeterministic under multi-touch. Verified on iOS and Android against mobile Chrome.
1 parent baf8d71 commit bf6fed5

4 files changed

Lines changed: 296 additions & 109 deletions

File tree

packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.swmansion.reanimated.pseudoSelectors
22

33
import android.view.MotionEvent
44
import android.view.View
5+
import android.view.ViewGroup
56
import android.view.ViewParent
67
import com.facebook.react.bridge.UIManager
78
import com.facebook.react.bridge.UIManagerListener
@@ -198,6 +199,10 @@ class PseudoSelectorManager(
198199
return
199200
}
200201
view.setOnTouchListener { _, event ->
202+
// Feed the hover model from the touched view so it works inside a Modal/Dialog (a
203+
// separate window the window observer is blind to); the guards keep it idempotent when
204+
// the window observer already drove the same Activity-window gesture.
205+
hover.onTouchEvent(event, view.rootView as? ViewGroup)
201206
when (event.actionMasked) {
202207
MotionEvent.ACTION_DOWN -> {
203208
fireActiveCallbacksUpTree(view, true)
@@ -206,7 +211,6 @@ class PseudoSelectorManager(
206211
it.onSelectorStateChanged(true)
207212
}
208213
}
209-
hover.recompute(view, event.rawX, event.rawY)
210214
}
211215
MotionEvent.ACTION_UP -> {
212216
fireActiveCallbacksUpTree(view, false)
@@ -215,7 +219,6 @@ class PseudoSelectorManager(
215219
MotionEvent.ACTION_CANCEL -> {
216220
fireActiveCallbacksUpTree(view, false)
217221
deepestCallbacks[view]?.onSelectorStateChanged(false)
218-
hover.clearAll()
219222
}
220223
}
221224
false

packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt

Lines changed: 157 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,22 @@ import com.swmansion.reanimated.nativeProxy.PseudoSelectorCallback
1414
import java.lang.ref.WeakReference
1515

1616
/**
17-
* Drives sticky touch :hover (Chromium model): a tapped view stays hovered after the finger lifts,
18-
* clearing only when a later touch lands elsewhere or a scroll cancels it. The hosting manager feeds
19-
* it touch-downs (per-view, plus a window observer for blank space). register also wires the pointer
20-
* (mouse/stylus) hover, which stays non-sticky.
17+
* Drives touch `:hover` to match the web (Chromium): a tap makes the touched view sticky-hovered, a
18+
* pan/scroll never changes the committed hover (it rolls back on release), and only the first finger
19+
* counts - the rest are ignored until it lifts.
20+
*
21+
* `committed` is the sticky hover a tap leaves behind; `displayed` is what is shown right now. Touch
22+
* events arrive from two places that feed the same single-primary-touch state machine: the window
23+
* observer (gestures anywhere in the Activity window, which it sees first and so claims) and the
24+
* per-view listener (gestures inside a Modal/Dialog, a separate window the observer is blind to).
25+
* Both forwarding the same Activity gesture is harmless - the guards keep it idempotent.
26+
*
27+
* register also wires the pointer (mouse/stylus) hover, which stays live and non-sticky.
2128
*/
2229
class TouchHoverCoordinator {
2330
private val hoverCallbacks = LinkedHashMap<View, PseudoSelectorCallback>()
24-
private val hoveredViews = LinkedHashSet<View>()
31+
private val displayed = LinkedHashSet<View>()
32+
private val committed = LinkedHashSet<View>()
2533
private val tmpLocation = IntArray(2)
2634
private val tmpCoords = FloatArray(2)
2735

@@ -30,28 +38,56 @@ class TouchHoverCoordinator {
3038
private var originalWindowCallback: WeakReference<Window.Callback>? = null
3139
private var wrappedWindowCallback: WeakReference<Window.Callback>? = null
3240

41+
// The first finger of a gesture owns `:hover`; later fingers are ignored until it lifts.
42+
private var primaryActive = false
43+
private var primaryDownTime = 0L
44+
private var primaryPointerId = -1
45+
private var downRawX = 0f
46+
private var downRawY = 0f
47+
private var panned = false
48+
private var downOnBlank = false
49+
private var rolledBackForScroll = false
50+
private var touchSlop = 0
51+
3352
fun register(
3453
view: View,
3554
callback: PseudoSelectorCallback,
3655
) {
3756
view.setOnHoverListener { _, event ->
57+
// Real pointer (mouse/stylus) hover: live, non-sticky - keep committed in sync with it.
58+
// Ignored while a finger gesture owns the state, so a stray hover event can't corrupt the
59+
// in-flight gesture's rollback target.
3860
when (event.actionMasked) {
3961
MotionEvent.ACTION_HOVER_ENTER,
62+
MotionEvent.ACTION_HOVER_MOVE,
4063
MotionEvent.ACTION_HOVER_EXIT,
41-
-> recompute(view, event.rawX, event.rawY)
64+
->
65+
if (!primaryActive) {
66+
displayHitPath(view.rootView as? ViewGroup, event.rawX, event.rawY)
67+
committed.clear()
68+
committed.addAll(displayed)
69+
}
4270
}
4371
false
4472
}
4573
hoverCallbacks[view] = callback
74+
if (touchSlop == 0) {
75+
touchSlop = ViewConfiguration.get(view.context).scaledTouchSlop
76+
}
4677
ensureWindowObserver(view)
4778
}
4879

4980
fun unregister(view: View) {
5081
view.setOnHoverListener(null)
5182
val callback = hoverCallbacks.remove(view)
52-
if (hoveredViews.remove(view)) {
83+
committed.remove(view)
84+
if (displayed.remove(view)) {
5385
callback?.onSelectorStateChanged(false)
5486
}
87+
// Drop any in-flight gesture: a view torn down mid-press (e.g. a Modal driving the primary
88+
// touch via its own window) might never deliver the terminal up/cancel, which would otherwise
89+
// leave `primaryActive` stuck and silently ignore every later touch. The next touch re-claims.
90+
primaryActive = false
5591
if (hoverCallbacks.isEmpty()) {
5692
removeWindowObserver()
5793
}
@@ -60,38 +96,113 @@ class TouchHoverCoordinator {
6096
fun isRegistered(view: View) = view in hoverCallbacks
6197

6298
/**
63-
* Mirrors CSS hit-testing: turns :hover on for the topmost view at the touch and its registered
64-
* ancestors, off for the rest. Views that merely overlap the hit branch (which a plain bounds
65-
* test would all activate) stay off, because only the hit branch is hovered. Rooted on the
66-
* touched view's own window so it works inside a Modal/Dialog (a separate window from the Activity).
99+
* Single entry point for the touch model, fed by both the per-view listener (passing the touched
100+
* view's root, so Modal/Dialog windows work) and the window observer (passing the decor view).
101+
* Only ACTION_DOWN/MOVE/UP/CANCEL of the first finger matter.
67102
*/
68-
fun recompute(
69-
sourceView: View,
70-
screenX: Float,
71-
screenY: Float,
103+
fun onTouchEvent(
104+
event: MotionEvent,
105+
root: ViewGroup?,
72106
) {
73-
reconcile(sourceView.rootView as? ViewGroup, screenX, screenY)
107+
when (event.actionMasked) {
108+
MotionEvent.ACTION_DOWN -> onPrimaryDown(event, root)
109+
MotionEvent.ACTION_MOVE -> onPrimaryMove(event)
110+
MotionEvent.ACTION_POINTER_UP ->
111+
if (event.getPointerId(event.actionIndex) == primaryPointerId) {
112+
onPrimaryEnd(event)
113+
}
114+
MotionEvent.ACTION_UP -> onPrimaryEnd(event)
115+
MotionEvent.ACTION_CANCEL -> onPrimaryCancel(event)
116+
}
74117
}
75118

76-
// Blank-space (window observer) path: no source view, so hit-test the observed window's tree.
77-
fun recompute(
78-
screenX: Float,
79-
screenY: Float,
119+
private fun onPrimaryDown(
120+
event: MotionEvent,
121+
root: ViewGroup?,
80122
) {
81-
reconcile(hoverRootViewGroup(), screenX, screenY)
123+
if (primaryActive || hoverCallbacks.isEmpty()) {
124+
return
125+
}
126+
primaryActive = true
127+
primaryDownTime = event.downTime
128+
primaryPointerId = event.getPointerId(0)
129+
downRawX = event.rawX
130+
downRawY = event.rawY
131+
panned = false
132+
rolledBackForScroll = false
133+
// Reconcile the live hover to the touched branch: press feedback on it, previous hover
134+
// cleared right away (restored on release if this turns into a pan/scroll).
135+
displayHitPath(root, event.rawX, event.rawY)
136+
// Empty hit-path == the gesture began off every hover view (blank space / a scroll surface).
137+
downOnBlank = displayed.isEmpty()
138+
}
139+
140+
private fun onPrimaryMove(event: MotionEvent) {
141+
if (!primaryActive || event.downTime != primaryDownTime) {
142+
return
143+
}
144+
// event.rawX/rawY track the first finger (pointer index 0) for the whole gesture.
145+
if (movedPastSlop(event)) {
146+
// A drag/scroll: keep showing the down branch (so hover stays while the finger is over
147+
// the view) but mark it so the release rolls back to the committed hover instead of
148+
// committing this branch.
149+
panned = true
150+
}
151+
// A drag that began off every hover view (typically a scroll) can never commit a new branch,
152+
// so roll back to the committed hover right away instead of waiting for release. This keeps a
153+
// sticky-hovered view visible while scrolling from blank space (matching iOS, where the
154+
// enclosing scroll view's drag is detected). A drag that began on a hover view is left alone:
155+
// its release decides, and a scroll over it is rolled back by the per-view touch-cancel.
156+
if (panned && downOnBlank && !rolledBackForScroll) {
157+
rolledBackForScroll = true
158+
revertToCommitted()
159+
}
160+
}
161+
162+
// ACTION_UP always carries the final position, so deciding tap-vs-pan from the lift point is
163+
// robust even when a scrolling child swallows the intervening ACTION_MOVE events at the window.
164+
private fun movedPastSlop(event: MotionEvent): Boolean {
165+
val dx = event.rawX - downRawX
166+
val dy = event.rawY - downRawY
167+
return dx * dx + dy * dy > touchSlop.toFloat() * touchSlop
82168
}
83169

84-
private fun reconcile(
170+
private fun onPrimaryEnd(event: MotionEvent) {
171+
if (!primaryActive || event.downTime != primaryDownTime) {
172+
return
173+
}
174+
if (movedPastSlop(event)) {
175+
panned = true
176+
}
177+
primaryActive = false
178+
if (panned) {
179+
revertToCommitted() // A pan/scroll never changes the sticky hover.
180+
} else {
181+
commitDisplayed() // A tap makes the touched branch sticky.
182+
}
183+
}
184+
185+
private fun onPrimaryCancel(event: MotionEvent) {
186+
if (!primaryActive || event.downTime != primaryDownTime) {
187+
return
188+
}
189+
primaryActive = false
190+
revertToCommitted()
191+
}
192+
193+
/**
194+
* Mirrors CSS hit-testing: turns hover on for the topmost view at the point and its registered
195+
* ancestors, off for the rest. Views that merely overlap the hit branch (which a plain bounds
196+
* test would all activate) stay off, because only the hit branch is hovered.
197+
*/
198+
private fun displayHitPath(
85199
root: ViewGroup?,
86200
screenX: Float,
87201
screenY: Float,
88202
) {
89-
if (hoverCallbacks.isEmpty()) {
90-
return
91-
}
92203
val hitPath: Set<View> = if (root == null) emptySet() else hitTestPath(root, screenX, screenY)
93204
for ((view, callback) in hoverCallbacks) {
94-
setHovered(view, callback, view in hitPath)
205+
setDisplayed(view, callback, view in hitPath)
95206
}
96207
}
97208

@@ -114,33 +225,38 @@ class TouchHoverCoordinator {
114225
return views
115226
}
116227

117-
private fun hoverRootViewGroup(): ViewGroup? {
118-
val window = observedWindow?.get() ?: hoverCallbacks.keys.firstOrNull()?.activityWindow()
119-
return window?.decorView as? ViewGroup
120-
}
121-
122228
fun clearAll() {
123-
if (hoveredViews.isEmpty()) {
124-
return
229+
for ((view, callback) in hoverCallbacks) {
230+
setDisplayed(view, callback, false)
125231
}
126-
for (view in hoveredViews.toList()) {
127-
hoverCallbacks[view]?.let { setHovered(view, it, false) }
232+
committed.clear()
233+
}
234+
235+
private fun commitDisplayed() {
236+
committed.clear()
237+
committed.addAll(displayed)
238+
}
239+
240+
private fun revertToCommitted() {
241+
for ((view, callback) in hoverCallbacks) {
242+
setDisplayed(view, callback, view in committed)
128243
}
129244
}
130245

131-
private fun setHovered(
246+
private fun setDisplayed(
132247
view: View,
133248
callback: PseudoSelectorCallback,
134249
hovered: Boolean,
135250
) {
136-
if ((view in hoveredViews) == hovered) {
251+
if ((view in displayed) == hovered) {
137252
return
138253
}
139-
if (hovered) hoveredViews.add(view) else hoveredViews.remove(view)
254+
if (hovered) displayed.add(view) else displayed.remove(view)
140255
callback.onSelectorStateChanged(hovered)
141256
}
142257

143-
// Catches touch-downs on blank space (off any registered view), which per-view listeners miss.
258+
// Catches gestures on blank space (off any registered view), which per-view listeners miss, and
259+
// drives every Activity-window gesture (it sees them before the per-view listeners).
144260
private fun ensureWindowObserver(view: View) {
145261
val window = view.activityWindow() ?: return
146262
if (observedWindow?.get() === window) {
@@ -149,33 +265,10 @@ class TouchHoverCoordinator {
149265
// The Activity (and its window) can be replaced; re-bind onto the live one.
150266
removeWindowObserver()
151267
val original = window.callback ?: return
152-
val slop = ViewConfiguration.get(view.context).scaledTouchSlop.toFloat()
153268
val wrapper =
154269
object : Window.Callback by original {
155-
private var startX = 0f
156-
private var startY = 0f
157-
private var slopExceeded = false
158-
159270
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
160-
when (event.actionMasked) {
161-
MotionEvent.ACTION_DOWN -> {
162-
startX = event.rawX
163-
startY = event.rawY
164-
slopExceeded = false
165-
recompute(event.rawX, event.rawY)
166-
}
167-
// A scroll/drag past slop dismisses sticky :hover, matching iOS.
168-
MotionEvent.ACTION_MOVE ->
169-
if (!slopExceeded) {
170-
val dx = event.rawX - startX
171-
val dy = event.rawY - startY
172-
if (dx * dx + dy * dy > slop * slop) {
173-
slopExceeded = true
174-
clearAll()
175-
}
176-
}
177-
MotionEvent.ACTION_CANCEL -> clearAll()
178-
}
271+
onTouchEvent(event, window.decorView as? ViewGroup)
179272
return original.dispatchTouchEvent(event)
180273
}
181274
}
@@ -195,6 +288,7 @@ class TouchHoverCoordinator {
195288
observedWindow = null
196289
originalWindowCallback = null
197290
wrappedWindowCallback = null
291+
primaryActive = false
198292
clearAll()
199293
}
200294

packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.h

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
#import <UIKit/UIKit.h>
66
#import <functional>
77

8-
/// Drives sticky touch `:hover` (Chromium model): a tapped view stays `:hover` after the finger
9-
/// lifts, clearing only when a later touch lands elsewhere or a scroll cancels it. A single passive
10-
/// key-window touch observer recomputes which registered views contain each touch-down.
8+
/// Drives touch `:hover` to match mobile Chromium: a tap makes the touched view's hit-path sticky
9+
/// (`committed`), while a pan/scroll never changes the committed `:hover` (the live `displayed` state
10+
/// rolls back to it on release, or as soon as scrolling starts). Only the first finger counts; later
11+
/// fingers are ignored until it lifts. A single passive key-window touch observer feeds this.
1112
@interface REATouchHoverCoordinator : NSObject
1213
+ (instancetype)sharedCoordinator;
1314
- (void)registerObserver:(id)owner view:(UIView *)view callback:(std::function<void(bool)>)callback;

0 commit comments

Comments
 (0)