Skip to content

Commit 3cc7b55

Browse files
committed
Decide touch :hover by touch-down and release locations only
1 parent 4f032e7 commit 3cc7b55

5 files changed

Lines changed: 213 additions & 82 deletions

File tree

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -243,17 +243,33 @@ class PseudoSelectorManager(
243243
host.setOnTouchListener(null)
244244
}
245245

246+
// Only the first finger (pointer id 0) drives the press selectors, matching the web's single
247+
// active pointer; its release can arrive as the last-finger up or early via a pointer-up while
248+
// other fingers stay down.
246249
private fun onHostTouch(
247250
host: View,
248251
event: MotionEvent,
249252
): Boolean {
250253
when (event.actionMasked) {
251-
MotionEvent.ACTION_DOWN -> onHostDown(host, event)
252-
MotionEvent.ACTION_UP -> onHostRelease(host)
253-
MotionEvent.ACTION_CANCEL -> {
254-
onHostRelease(host)
255-
hover.clearAll()
256-
}
254+
MotionEvent.ACTION_DOWN ->
255+
if (event.getPointerId(0) == 0 && !hover.isGestureSettled(event)) {
256+
onHostDown(host, event)
257+
}
258+
MotionEvent.ACTION_POINTER_UP ->
259+
if (event.getPointerId(event.actionIndex) == 0) {
260+
onHostRelease(host)
261+
hover.onViewTouchUp(host, event)
262+
}
263+
MotionEvent.ACTION_UP ->
264+
if (event.findPointerIndex(0) >= 0) {
265+
onHostRelease(host)
266+
hover.onViewTouchUp(host, event)
267+
}
268+
MotionEvent.ACTION_CANCEL ->
269+
if (event.findPointerIndex(0) >= 0) {
270+
onHostRelease(host)
271+
hover.onViewTouchCancel(event)
272+
}
257273
}
258274
return false
259275
}

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

Lines changed: 71 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import android.content.Context
55
import android.content.ContextWrapper
66
import android.view.MotionEvent
77
import android.view.View
8-
import android.view.ViewConfiguration
98
import android.view.ViewGroup
109
import android.view.Window
1110
import com.facebook.react.bridge.ReactContext
@@ -14,10 +13,12 @@ import com.swmansion.reanimated.nativeProxy.PseudoSelectorCallback
1413
import java.lang.ref.WeakReference
1514

1615
/**
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.
16+
* Drives sticky touch :hover: only the touch-down and release locations matter. A touch-down hovers
17+
* the views on its hit branch (and unhovers the rest, including on blank space), and when the first
18+
* finger lifts, a hovered view stays hovered only if the finger is still over it - moves and scrolls
19+
* in between change nothing. The Activity-window observer sees whole gestures; the hosting manager
20+
* feeds per-view touches as the fallback for windows the observer is blind to (Modal/Dialog).
21+
* register also wires the pointer (mouse/stylus) hover, which stays non-sticky.
2122
*/
2223
class TouchHoverCoordinator {
2324
private val hoverCallbacks = LinkedHashMap<View, PseudoSelectorCallback>()
@@ -30,6 +31,10 @@ class TouchHoverCoordinator {
3031
// prop, which for svg invalidates the front element's path so it resolves the one behind it.
3132
private var observedGestureDownTime = Long.MIN_VALUE
3233

34+
// downTime of the gesture whose release was already settled, so a later finger that inherits
35+
// pointer id 0 mid-gesture (or the per-view listener echoing the observer) cannot settle again.
36+
private var settledGestureDownTime = Long.MIN_VALUE
37+
3338
// Weak so a stale wrapper can never pin a destroyed Activity (this outlives Activities).
3439
private var observedWindow: WeakReference<Window>? = null
3540
private var originalWindowCallback: WeakReference<Window.Callback>? = null
@@ -84,12 +89,36 @@ class TouchHoverCoordinator {
8489
sourceView: View,
8590
event: MotionEvent,
8691
) {
87-
if (event.downTime == observedGestureDownTime) {
92+
if (event.downTime == observedGestureDownTime || isGestureSettled(event)) {
8893
return
8994
}
9095
reconcile(sourceView.rootView as? ViewGroup, event.rawX, event.rawY)
9196
}
9297

98+
// The Modal/Dialog counterparts of the observer's release handling. A cancel there ends the
99+
// gesture for good (the real release is never delivered), so the sticky :hover is dropped.
100+
fun onViewTouchUp(
101+
sourceView: View,
102+
event: MotionEvent,
103+
) {
104+
if (event.downTime == observedGestureDownTime) {
105+
return
106+
}
107+
settleHover(sourceView.rootView as? ViewGroup, event)
108+
}
109+
110+
fun onViewTouchCancel(event: MotionEvent) {
111+
if (event.downTime == observedGestureDownTime || isGestureSettled(event)) {
112+
return
113+
}
114+
settledGestureDownTime = event.downTime
115+
clearAll()
116+
}
117+
118+
// Once a gesture's first finger released (or its cancel cleared), later fingers that inherit
119+
// pointer id 0 within the same gesture must stay ignored, web-like.
120+
fun isGestureSettled(event: MotionEvent) = event.downTime == settledGestureDownTime
121+
93122
// Blank-space (window observer) path: no source view, so hit-test the observed window's tree.
94123
fun recompute(
95124
screenX: Float,
@@ -112,6 +141,31 @@ class TouchHoverCoordinator {
112141
}
113142
}
114143

144+
// The first finger lifted: a hovered view stays hovered only if the finger is still over it.
145+
// Screen coords of the lifting pointer come from the index-0 raw/local delta (getRawX/Y(index)
146+
// needs API 29); nothing new is ever hovered here.
147+
private fun settleHover(
148+
root: ViewGroup?,
149+
event: MotionEvent,
150+
) {
151+
if (event.downTime == settledGestureDownTime) {
152+
return
153+
}
154+
settledGestureDownTime = event.downTime
155+
val index = event.findPointerIndex(0)
156+
if (index < 0 || hoveredViews.isEmpty()) {
157+
return
158+
}
159+
val screenX = event.getX(index) + (event.rawX - event.getX(0))
160+
val screenY = event.getY(index) + (event.rawY - event.getY(0))
161+
val hitTags: Set<Int> = if (root == null) emptySet() else hitTestPath(root, screenX, screenY)
162+
for (view in hoveredViews.toList()) {
163+
if (view.id !in hitTags) {
164+
hoverCallbacks[view]?.let { setHovered(view, it, false) }
165+
}
166+
}
167+
}
168+
115169
// React tags on the hit branch (RN's hit-test honors z-order/transforms/clipping/pointer-events).
116170
// Matching by tag also covers svg's virtual children, whose tag rides the path with a null view.
117171
private fun hitTestPath(
@@ -132,7 +186,7 @@ class TouchHoverCoordinator {
132186
return window?.decorView as? ViewGroup
133187
}
134188

135-
fun clearAll() {
189+
private fun clearAll() {
136190
if (hoveredViews.isEmpty()) {
137191
return
138192
}
@@ -162,33 +216,25 @@ class TouchHoverCoordinator {
162216
// The Activity (and its window) can be replaced; re-bind onto the live one.
163217
removeWindowObserver()
164218
val original = window.callback ?: return
165-
val slop = ViewConfiguration.get(view.context).scaledTouchSlop.toFloat()
166219
val wrapper =
167220
object : Window.Callback by original {
168-
private var startX = 0f
169-
private var startY = 0f
170-
private var slopExceeded = false
171-
172221
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
173222
when (event.actionMasked) {
174223
MotionEvent.ACTION_DOWN -> {
175-
startX = event.rawX
176-
startY = event.rawY
177-
slopExceeded = false
178224
observedGestureDownTime = event.downTime
179225
recompute(event.rawX, event.rawY)
180226
}
181-
// A scroll/drag past slop dismisses sticky :hover, matching iOS.
182-
MotionEvent.ACTION_MOVE ->
183-
if (!slopExceeded) {
184-
val dx = event.rawX - startX
185-
val dy = event.rawY - startY
186-
if (dx * dx + dy * dy > slop * slop) {
187-
slopExceeded = true
188-
clearAll()
189-
}
227+
// Only the first finger's release settles the gesture; it can arrive as the
228+
// last-finger up or early, while other fingers stay down. A cancel is a system
229+
// interruption, not a release, so it keeps the :hover.
230+
MotionEvent.ACTION_UP ->
231+
if (event.findPointerIndex(0) >= 0) {
232+
settleHover(hoverRootViewGroup(), event)
233+
}
234+
MotionEvent.ACTION_POINTER_UP ->
235+
if (event.getPointerId(event.actionIndex) == 0) {
236+
settleHover(hoverRootViewGroup(), event)
190237
}
191-
MotionEvent.ACTION_CANCEL -> clearAll()
192238
}
193239
return original.dispatchTouchEvent(event)
194240
}

packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REAPseudoSelectorObserver.mm

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,19 @@ @implementation REAPseudoSelectorObserver {
3636
#endif
3737
NSArray *_notificationObservers;
3838
std::function<void(bool)> _callback;
39+
// Whether this observer's `:active` recognizer is counted in the shared active-touch tally below.
40+
BOOL _activeTouchCounted;
3941
}
4042

43+
#if !TARGET_OS_OSX
44+
// Single active touch: the first finger to press an `:active` / `:active-deepest` view owns it, and
45+
// other fingers are ignored until it (and everything it activated) lifts - matching the web. The
46+
// touch is shared across all observers; the count tracks how many of its recognizers are live so the
47+
// owner can be released once the last one ends.
48+
static __weak UITouch *sActivePrimaryTouch;
49+
static NSInteger sActiveTouchCount;
50+
#endif
51+
4152
- (instancetype)initWithView:(REAUIView *)view
4253
selector:(reanimated::PseudoSelector)selector
4354
callback:(std::function<void(bool)>)callback
@@ -260,17 +271,60 @@ - (void)handleTouchGesture:(UIGestureRecognizer *)recognizer
260271
switch (recognizer.state) {
261272
case UIGestureRecognizerStateBegan:
262273
_callback(true);
274+
[self retainActiveTouch];
263275
break;
264276
case UIGestureRecognizerStateEnded:
265277
case UIGestureRecognizerStateCancelled:
266278
case UIGestureRecognizerStateFailed:
267279
_callback(false);
280+
[self releaseActiveTouch];
268281
break;
269282
default:
270283
break;
271284
}
272285
}
273286

287+
// Single active touch is gated here: the first finger claims `sActivePrimaryTouch`; a later finger on
288+
// another `:active` view is a different UITouch and is refused, so only the first press takes effect.
289+
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
290+
{
291+
if (![self participatesInActiveDeepestArbitration]) {
292+
return YES;
293+
}
294+
// A claim made here is only counted once a recognizer actually begins; a touch that claims but never
295+
// begins (e.g. refused in `gestureRecognizerShouldBegin`) would otherwise pin the token until its
296+
// `UITouch` deallocates. When no gesture is in flight the count is back to zero, so any lingering
297+
// token is stale and the next first finger reclaims it.
298+
if (sActiveTouchCount == 0) {
299+
sActivePrimaryTouch = nil;
300+
}
301+
if (sActivePrimaryTouch == nil) {
302+
sActivePrimaryTouch = touch;
303+
return YES;
304+
}
305+
return sActivePrimaryTouch == touch;
306+
}
307+
308+
- (void)retainActiveTouch
309+
{
310+
if ([self participatesInActiveDeepestArbitration] && !_activeTouchCounted) {
311+
_activeTouchCounted = YES;
312+
sActiveTouchCount++;
313+
}
314+
}
315+
316+
- (void)releaseActiveTouch
317+
{
318+
if (!_activeTouchCounted) {
319+
return;
320+
}
321+
_activeTouchCounted = NO;
322+
if (--sActiveTouchCount <= 0) {
323+
sActiveTouchCount = 0;
324+
sActivePrimaryTouch = nil;
325+
}
326+
}
327+
274328
#else // TARGET_OS_OSX
275329

276330
- (void)mouseEntered:(NSEvent *)event
@@ -369,11 +423,16 @@ - (void)detach
369423
[_view removeGestureRecognizer:_gestureRecognizer];
370424
}
371425
_gestureRecognizer = nil;
372-
#if !TARGET_OS_OSX && !TARGET_OS_TV
426+
#if !TARGET_OS_OSX
427+
// Release this observer's hold on the active touch so tearing it down mid-press cannot wedge the
428+
// shared count and silently block every later `:active`.
429+
[self releaseActiveTouch];
430+
#if !TARGET_OS_TV
373431
if (_selector == reanimated::PseudoSelector::Hover) {
374432
[[REATouchHoverCoordinator sharedCoordinator] unregisterObserver:self];
375433
}
376434
#endif
435+
#endif
377436
#if TARGET_OS_OSX
378437
if (_trackingArea && _view) {
379438
[_view removeTrackingArea:_trackingArea];

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
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 sticky touch `:hover`: only the touch-down and release locations matter. A touch-down hovers
9+
/// the views on its hit branch (and unhovers the rest, including on blank space), and when the first
10+
/// finger lifts, a hovered view stays hovered only if the finger is still over it - moves and scrolls
11+
/// in between change nothing. Later fingers are ignored until the first lifts. A single passive
12+
/// key-window touch observer feeds this.
1113
@interface REATouchHoverCoordinator : NSObject
1214
+ (instancetype)sharedCoordinator;
1315
- (void)registerObserver:(id)owner view:(UIView *)view callback:(std::function<void(bool)>)callback;

0 commit comments

Comments
 (0)