From e9e76cd9e5285b5fd21cf44bf6dfb96997a97214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 22 Jun 2026 01:14:36 +0200 Subject: [PATCH 1/6] Route :active and :active-deepest through a shared touch listener A View exposes a single OnTouchListener slot, so attaching both :active and :active-deepest to one view made whichever selector registered second overwrite the first. Funnel both through one shared listener keyed by activeCallbacks and deepestCallbacks so they coexist. --- .../pseudoSelectors/PseudoSelectorManager.kt | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt index 555b5a7de36c..37cc7202d0d2 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt @@ -14,8 +14,9 @@ class PseudoSelectorManager( private val detachActions = HashMap() private val activeCallbacks = LinkedHashMap() + private val deepestCallbacks = LinkedHashMap() - private val activeDeepestViews = LinkedHashSet() + private val touchListenerViews = HashSet() fun attach( tag: Int, @@ -104,21 +105,11 @@ class PseudoSelectorManager( callback: PseudoSelectorCallback, ) { activeCallbacks[view] = callback - val listener = - View.OnTouchListener { _, event -> - val action = event.actionMasked - if (action == MotionEvent.ACTION_DOWN) { - fireActiveCallbacksUpTree(view, true) - } else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { - fireActiveCallbacksUpTree(view, false) - } - false - } - view.setOnTouchListener(listener) + ensureTouchListener(view) detachActions[key] = Runnable { activeCallbacks.remove(view) - view.setOnTouchListener(null) + maybeRemoveTouchListener(view) } } @@ -127,27 +118,43 @@ class PseudoSelectorManager( key: String, callback: PseudoSelectorCallback, ) { - activeDeepestViews.add(view) - val listener = - View.OnTouchListener { _, event -> - val action = event.actionMasked - if (action == MotionEvent.ACTION_DOWN) { - if (!hasDeepestDescendantAt(view, event.rawX, event.rawY)) { - callback.onSelectorStateChanged(true) - } + deepestCallbacks[view] = callback + ensureTouchListener(view) + detachActions[key] = + Runnable { + deepestCallbacks.remove(view) + maybeRemoveTouchListener(view) + } + } + + private fun ensureTouchListener(view: View) { + if (!touchListenerViews.add(view)) { + return + } + view.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { fireActiveCallbacksUpTree(view, true) - } else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { - callback.onSelectorStateChanged(false) + deepestCallbacks[view]?.let { + if (!hasDeepestDescendantAt(view, event.rawX, event.rawY)) { + it.onSelectorStateChanged(true) + } + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { fireActiveCallbacksUpTree(view, false) + deepestCallbacks[view]?.onSelectorStateChanged(false) } - false - } - view.setOnTouchListener(listener) - detachActions[key] = - Runnable { - activeDeepestViews.remove(view) - view.setOnTouchListener(null) } + false + } + } + + private fun maybeRemoveTouchListener(view: View) { + if (view !in activeCallbacks && view !in deepestCallbacks) { + touchListenerViews.remove(view) + view.setOnTouchListener(null) + } } fun detach( @@ -158,18 +165,22 @@ class PseudoSelectorManager( } /** - * Returns true if any view in `activeDeepestViews` is a strict descendant of `ancestor` - * and contains the screen point (`rawX`, `rawY`). - * Used by _:active-deepest_ to yield priority to deeper registered views. + * Returns true if any view registered for _:active-deepest_ is a strict descendant of + * `ancestor` and contains the screen point (`rawX`, `rawY`), so the ancestor yields priority + * to the deeper view. */ private fun hasDeepestDescendantAt( ancestor: View, rawX: Float, rawY: Float, ): Boolean { + // With fewer than two registered views the only candidate is the ancestor itself. + if (deepestCallbacks.size < 2) { + return false + } val loc = IntArray(2) // TODO: Optimize so we don't iterate over all the views with :active-deepest every time. - for (candidate in activeDeepestViews) { + for (candidate in deepestCallbacks.keys) { if (candidate === ancestor) continue if (!isDescendantOf(candidate, ancestor)) continue candidate.getLocationOnScreen(loc) From 1e2012165709d526ba2561f134e6dc72070d5a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sat, 20 Jun 2026 18:12:18 +0200 Subject: [PATCH 2/6] Support the :hover pseudo-selector on touch devices On a touchscreen the pointer-only recognizers never fire for a finger, so :hover did nothing. Apply :hover while a finger is pressing a view and clear it on lift, matching Chromium-based browsers (non-sticky) - the same lifecycle as :active. Both platforms drive it from the per-view press path that :active already uses: iOS adds a min-duration UILongPressGestureRecognizer, Android adds a per-view OnTouchListener shared with :active. Real-pointer hover (trackpad, mouse, stylus) is unchanged. --- .../pseudoSelectors/PseudoSelectorManager.kt | 29 ++- .../pseudoSelectors/TouchHoverCoordinator.kt | 151 +++++++++++ .../REAPseudoSelectorObserver.h | 3 +- .../REAPseudoSelectorObserver.mm | 53 ++-- .../REATouchHoverCoordinator.h | 17 ++ .../REATouchHoverCoordinator.mm | 237 ++++++++++++++++++ 6 files changed, 447 insertions(+), 43 deletions(-) create mode 100644 packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt create mode 100644 packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.h create mode 100644 packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt index 37cc7202d0d2..eed1a3de0893 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt @@ -18,6 +18,8 @@ class PseudoSelectorManager( private val touchListenerViews = HashSet() + private val hover = TouchHoverCoordinator() + fun attach( tag: Int, selector: Int, @@ -85,18 +87,13 @@ class PseudoSelectorManager( key: String, callback: PseudoSelectorCallback, ) { - val listener = - View.OnHoverListener { _, event -> - val action = event.actionMasked - if (action == MotionEvent.ACTION_HOVER_ENTER) { - callback.onSelectorStateChanged(true) - } else if (action == MotionEvent.ACTION_HOVER_EXIT) { - callback.onSelectorStateChanged(false) - } - false + ensureTouchListener(view) + hover.register(view, callback) + detachActions[key] = + Runnable { + hover.unregister(view) + maybeRemoveTouchListener(view) } - view.setOnHoverListener(listener) - detachActions[key] = Runnable { view.setOnHoverListener(null) } } private fun attachActive( @@ -140,10 +137,16 @@ class PseudoSelectorManager( it.onSelectorStateChanged(true) } } + hover.recompute(event.rawX, event.rawY) + } + MotionEvent.ACTION_UP -> { + fireActiveCallbacksUpTree(view, false) + deepestCallbacks[view]?.onSelectorStateChanged(false) } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + MotionEvent.ACTION_CANCEL -> { fireActiveCallbacksUpTree(view, false) deepestCallbacks[view]?.onSelectorStateChanged(false) + hover.clearAll() } } false @@ -151,7 +154,7 @@ class PseudoSelectorManager( } private fun maybeRemoveTouchListener(view: View) { - if (view !in activeCallbacks && view !in deepestCallbacks) { + if (view !in activeCallbacks && view !in deepestCallbacks && !hover.isRegistered(view)) { touchListenerViews.remove(view) view.setOnTouchListener(null) } diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt new file mode 100644 index 000000000000..97df7c422ff8 --- /dev/null +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt @@ -0,0 +1,151 @@ +package com.swmansion.reanimated.pseudoSelectors + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.MotionEvent +import android.view.View +import android.view.Window +import com.facebook.react.bridge.ReactContext +import com.swmansion.reanimated.nativeProxy.PseudoSelectorCallback +import java.lang.ref.WeakReference + +/** + * Drives sticky touch :hover (Chromium model): a tapped view stays hovered after the finger lifts, + * clearing only when a later touch lands elsewhere or a scroll cancels it. The hosting manager feeds + * it touch-downs (per-view, plus a window observer for blank space). register also wires the pointer + * (mouse/stylus) hover, which stays non-sticky. + */ +class TouchHoverCoordinator { + private val hoverCallbacks = LinkedHashMap() + private val hoveredViews = LinkedHashSet() + private val tmpLocation = IntArray(2) + + // Weak so a stale wrapper can never pin a destroyed Activity (this outlives Activities). + private var observedWindow: WeakReference? = null + private var originalWindowCallback: WeakReference? = null + private var wrappedWindowCallback: WeakReference? = null + + fun register( + view: View, + callback: PseudoSelectorCallback, + ) { + view.setOnHoverListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_HOVER_ENTER -> callback.onSelectorStateChanged(true) + MotionEvent.ACTION_HOVER_EXIT -> callback.onSelectorStateChanged(false) + } + false + } + hoverCallbacks[view] = callback + ensureWindowObserver(view) + } + + fun unregister(view: View) { + view.setOnHoverListener(null) + hoverCallbacks.remove(view) + hoveredViews.remove(view) + if (hoverCallbacks.isEmpty()) { + removeWindowObserver() + } + } + + fun isRegistered(view: View) = view in hoverCallbacks + + /** Turns :hover on for every registered view whose bounds contain the touch, off for the rest. */ + fun recompute( + screenX: Float, + screenY: Float, + ) { + if (hoverCallbacks.isEmpty()) { + return + } + for ((view, callback) in hoverCallbacks) { + setHovered(view, callback, isPointInViewOnScreen(view, screenX, screenY)) + } + } + + fun clearAll() { + if (hoveredViews.isEmpty()) { + return + } + for (view in hoveredViews.toList()) { + hoverCallbacks[view]?.let { setHovered(view, it, false) } + } + } + + private fun setHovered( + view: View, + callback: PseudoSelectorCallback, + hovered: Boolean, + ) { + if ((view in hoveredViews) == hovered) { + return + } + if (hovered) hoveredViews.add(view) else hoveredViews.remove(view) + callback.onSelectorStateChanged(hovered) + } + + private fun isPointInViewOnScreen( + view: View, + screenX: Float, + screenY: Float, + ): Boolean { + if (!view.isShown) { + return false + } + view.getLocationOnScreen(tmpLocation) + val left = tmpLocation[0] + val top = tmpLocation[1] + return screenX >= left && screenX <= left + view.width && + screenY >= top && screenY <= top + view.height + } + + // Catches touch-downs on blank space (off any registered view), which per-view listeners miss. + private fun ensureWindowObserver(view: View) { + val window = view.activityWindow() ?: return + if (observedWindow?.get() === window) { + return + } + // The Activity (and its window) can be replaced; re-bind onto the live one. + removeWindowObserver() + val original = window.callback ?: return + val wrapper = + object : Window.Callback by original { + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> recompute(event.rawX, event.rawY) + MotionEvent.ACTION_CANCEL -> clearAll() + } + return original.dispatchTouchEvent(event) + } + } + originalWindowCallback = WeakReference(original) + wrappedWindowCallback = WeakReference(wrapper) + observedWindow = WeakReference(window) + window.callback = wrapper + } + + private fun removeWindowObserver() { + val window = observedWindow?.get() + val wrapper = wrappedWindowCallback?.get() + // Restore only if our wrapper is still the live callback (nothing wrapped us afterwards). + if (window != null && wrapper != null && window.callback === wrapper) { + window.callback = originalWindowCallback?.get() + } + observedWindow = null + originalWindowCallback = null + wrappedWindowCallback = null + clearAll() + } + + private fun View.activityWindow(): Window? { + var ctx: Context? = context + while (ctx is ContextWrapper) { + if (ctx is Activity) return ctx.window + if (ctx is ReactContext) ctx.currentActivity?.let { return it.window } + ctx = ctx.baseContext + } + return (context as? ReactContext)?.currentActivity?.window + } +} diff --git a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REAPseudoSelectorObserver.h b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REAPseudoSelectorObserver.h index 10d469422d8a..feb073f09b15 100644 --- a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REAPseudoSelectorObserver.h +++ b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REAPseudoSelectorObserver.h @@ -19,7 +19,8 @@ NS_ASSUME_NONNULL_BEGIN * Focus - iOS: UITextField/UITextView editing notifications * macOS: NSWindow.firstResponder KVO observation * FocusWithin - Same as Focus, but matches any descendant gaining focus - * Hover - iOS: UIHoverGestureRecognizer (iOS 13+) + * Hover - iOS: UIHoverGestureRecognizer (real pointer) + a shared touch + * coordinator for sticky touch hover (Chromium model) * macOS: NSTrackingArea (mouseEntered/mouseExited) * tvOS: no-op (UIHoverGestureRecognizer is unavailable); * the observer is constructed but never fires. diff --git a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REAPseudoSelectorObserver.mm b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REAPseudoSelectorObserver.mm index e613cdfa7104..04681e9da688 100644 --- a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REAPseudoSelectorObserver.mm +++ b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REAPseudoSelectorObserver.mm @@ -1,4 +1,5 @@ #import +#import #if !TARGET_OS_OSX #import @@ -13,6 +14,7 @@ @interface REAPseudoSelectorObserver () #endif - (void)attachActiveGestureRecognizerToView:(REAUIView *)view; - (void)attachHoverToView:(REAUIView *)view; +- (BOOL)participatesInActiveDeepestArbitration; #if !TARGET_OS_OSX - (void)attachFocusToView:(REAUIView *)view; - (void)attachFocusWithinToView:(REAUIView *)view; @@ -79,11 +81,11 @@ - (void)attachActiveGestureRecognizerToView:(REAUIView *)view { #if !TARGET_OS_OSX UILongPressGestureRecognizer *recognizer = - [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleActiveGesture:)]; + [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleTouchGesture:)]; recognizer.cancelsTouchesInView = NO; #else NSPressGestureRecognizer *recognizer = - [[NSPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleActiveGesture:)]; + [[NSPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleTouchGesture:)]; #endif recognizer.minimumPressDuration = 0; recognizer.delegate = self; @@ -96,10 +98,13 @@ - (void)attachHoverToView:(REAUIView *)view #if !TARGET_OS_OSX #if !TARGET_OS_TV UIHoverGestureRecognizer *recognizer = - [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(handleHoverGesture:)]; + [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(handleTouchGesture:)]; recognizer.delegate = self; [view addGestureRecognizer:recognizer]; _gestureRecognizer = recognizer; + + // A finger never triggers UIHoverGestureRecognizer; the coordinator drives sticky touch `:hover`. + [[REATouchHoverCoordinator sharedCoordinator] registerObserver:self view:view callback:_callback]; #endif #else // TARGET_OS_OSX NSTrackingArea *trackingArea = [[NSTrackingArea alloc] @@ -250,25 +255,7 @@ - (void)attachFocusObserversWithPredicate:(BOOL (^)(NSNotification *))isOurs #if !TARGET_OS_OSX -#if !TARGET_OS_TV -- (void)handleHoverGesture:(UIHoverGestureRecognizer *)recognizer -{ - switch (recognizer.state) { - case UIGestureRecognizerStateBegan: - _callback(true); - break; - case UIGestureRecognizerStateEnded: - case UIGestureRecognizerStateCancelled: - case UIGestureRecognizerStateFailed: - _callback(false); - break; - default: - break; - } -} -#endif - -- (void)handleActiveGesture:(UILongPressGestureRecognizer *)recognizer +- (void)handleTouchGesture:(UIGestureRecognizer *)recognizer { switch (recognizer.state) { case UIGestureRecognizerStateBegan: @@ -296,7 +283,7 @@ - (void)mouseExited:(NSEvent *)event _callback(false); } -- (void)handleActiveGesture:(NSPressGestureRecognizer *)recognizer +- (void)handleTouchGesture:(NSPressGestureRecognizer *)recognizer { switch (recognizer.state) { case NSGestureRecognizerStateBegan: @@ -314,6 +301,11 @@ - (void)handleActiveGesture:(NSPressGestureRecognizer *)recognizer #endif // TARGET_OS_OSX +- (BOOL)participatesInActiveDeepestArbitration +{ + return _selector == reanimated::PseudoSelector::Active || _selector == reanimated::PseudoSelector::ActiveDeepest; +} + #if !TARGET_OS_OSX - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer #else @@ -330,16 +322,15 @@ - (BOOL)gestureRecognizerShouldBegin:(NSGestureRecognizer *)gestureRecognizer if (_selector != reanimated::PseudoSelector::ActiveDeepest) { return YES; } - // for :active-deepest walk up from the hit view: if any descendant - // already has an :active-deepest or :active gesture recognizer, that descendant - // owns the touch. + // For :active-deepest, walk up from the hit view: if a descendant already + // participates in :active-deepest arbitration, that descendant owns the touch. CGPoint location = [gestureRecognizer locationInView:view]; #if !TARGET_OS_OSX UIView *current = [view hitTest:location withEvent:nil]; while (current && current != view) { for (UIGestureRecognizer *gr in current.gestureRecognizers) { if ([gr.delegate isKindOfClass:[REAPseudoSelectorObserver class]] && - [gr isKindOfClass:[UILongPressGestureRecognizer class]]) { + [(REAPseudoSelectorObserver *)gr.delegate participatesInActiveDeepestArbitration]) { return NO; } } @@ -348,11 +339,10 @@ - (BOOL)gestureRecognizerShouldBegin:(NSGestureRecognizer *)gestureRecognizer #else NSPoint locationInSuper = [view convertPoint:location toView:view.superview]; NSView *current = [view.superview hitTest:locationInSuper]; - while (current && current != view) { for (NSGestureRecognizer *gr in current.gestureRecognizers) { if ([gr.delegate isKindOfClass:[REAPseudoSelectorObserver class]] && - [gr isKindOfClass:[NSPressGestureRecognizer class]]) { + [(REAPseudoSelectorObserver *)gr.delegate participatesInActiveDeepestArbitration]) { return NO; } } @@ -379,6 +369,11 @@ - (void)detach [_view removeGestureRecognizer:_gestureRecognizer]; } _gestureRecognizer = nil; +#if !TARGET_OS_OSX && !TARGET_OS_TV + if (_selector == reanimated::PseudoSelector::Hover) { + [[REATouchHoverCoordinator sharedCoordinator] unregisterObserver:self]; + } +#endif #if TARGET_OS_OSX if (_trackingArea && _view) { [_view removeTrackingArea:_trackingArea]; diff --git a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.h b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.h new file mode 100644 index 000000000000..18eef1c6ddec --- /dev/null +++ b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.h @@ -0,0 +1,17 @@ +#import + +#if !TARGET_OS_OSX && !TARGET_OS_TV + +#import +#import + +/// Drives sticky touch `:hover` (Chromium model): a tapped view stays `:hover` after the finger +/// lifts, clearing only when a later touch lands elsewhere or a scroll cancels it. A single passive +/// key-window touch observer recomputes which registered views contain each touch-down. +@interface REATouchHoverCoordinator : NSObject ++ (instancetype)sharedCoordinator; +- (void)registerObserver:(id)owner view:(UIView *)view callback:(std::function)callback; +- (void)unregisterObserver:(id)owner; +@end + +#endif diff --git a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm new file mode 100644 index 000000000000..eb35fa55980e --- /dev/null +++ b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm @@ -0,0 +1,237 @@ +#import + +#if !TARGET_OS_OSX && !TARGET_OS_TV + +#import + +/// One registered touch-`:hover` view (weak owner = unregister key, weak view = bounds). +@interface REATouchHoverEntry : NSObject { + @public + __weak id owner; + __weak UIView *view; + std::function callback; + BOOL hovered; +} +@end +@implementation REATouchHoverEntry +@end + +/// Passive key-window touch observer. It never leaves `.possible`, so it never claims the gesture +/// or interferes with the per-view `:active` / `:active-deepest` recognizers. +@interface REAHoverTouchObserver : UIGestureRecognizer +@property (nonatomic, weak) REATouchHoverCoordinator *coordinator; +@end + +@interface REATouchHoverCoordinator () +- (void)observeTouchBegan:(UITouch *)touch; +- (void)observeTouchMoved:(UITouch *)touch; +- (void)observeTouchCancelled; +@end + +@implementation REAHoverTouchObserver +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event +{ + [self.coordinator observeTouchBegan:touches.anyObject]; +} +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event +{ + [self.coordinator observeTouchMoved:touches.anyObject]; +} +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event +{ + self.state = UIGestureRecognizerStateFailed; +} +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event +{ + [self.coordinator observeTouchCancelled]; + self.state = UIGestureRecognizerStateFailed; +} +@end + +@implementation REATouchHoverCoordinator { + NSMutableArray *_entries; + REAHoverTouchObserver *_windowObserver; + __weak UIWindow *_observedWindow; + CGPoint _windowTouchStartScreen; +} + ++ (instancetype)sharedCoordinator +{ + static REATouchHoverCoordinator *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ instance = [[REATouchHoverCoordinator alloc] init]; }); + return instance; +} + +- (instancetype)init +{ + if (self = [super init]) { + _entries = [NSMutableArray array]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(refreshWindowObserver) + name:UIWindowDidBecomeKeyNotification + object:nil]; + } + return self; +} + +- (void)registerObserver:(id)owner view:(UIView *)view callback:(std::function)callback +{ + [self unregisterObserver:owner]; + REATouchHoverEntry *entry = [REATouchHoverEntry new]; + entry->owner = owner; + entry->view = view; + entry->callback = std::move(callback); + entry->hovered = NO; + [_entries addObject:entry]; + [self refreshWindowObserver]; +} + +- (void)unregisterObserver:(id)owner +{ + NSMutableArray *removed = [NSMutableArray array]; + for (REATouchHoverEntry *entry in _entries) { + if (entry->owner == nil || entry->owner == owner) { + [self setEntry:entry hovered:NO]; + [removed addObject:entry]; + } + } + [_entries removeObjectsInArray:removed]; + if (_entries.count == 0) { + [self removeWindowObserver]; + } +} + +- (UIWindow *)activeKeyWindow +{ + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + for (UIWindow *window in ((UIWindowScene *)scene).windows) { + if (window.isKeyWindow) { + return window; + } + } + } + return nil; +} + +- (void)refreshWindowObserver +{ + if (_entries.count == 0) { + return; + } + UIWindow *keyWindow = [self activeKeyWindow]; + if (keyWindow == nil || (_observedWindow == keyWindow && _windowObserver != nil)) { + return; + } + [self removeWindowObserver]; + REAHoverTouchObserver *observer = [[REAHoverTouchObserver alloc] init]; + observer.coordinator = self; + observer.cancelsTouchesInView = NO; + observer.delaysTouchesBegan = NO; + observer.delaysTouchesEnded = NO; + observer.delegate = self; + [keyWindow addGestureRecognizer:observer]; + _windowObserver = observer; + _observedWindow = keyWindow; +} + +- (void)removeWindowObserver +{ + if (_windowObserver != nil && _observedWindow != nil) { + [_observedWindow removeGestureRecognizer:_windowObserver]; + } + _windowObserver = nil; + _observedWindow = nil; +} + +- (void)observeTouchBegan:(UITouch *)touch +{ + UIWindow *window = _observedWindow; + if (window == nil || touch == nil) { + return; + } + CGPoint inWindow = [touch locationInView:window]; + CGPoint onScreen = [window convertPoint:inWindow toCoordinateSpace:window.screen.coordinateSpace]; + _windowTouchStartScreen = onScreen; + [self recomputeAtScreenPoint:onScreen]; +} + +- (void)observeTouchMoved:(UITouch *)touch +{ + // A scroll/drag (movement past a small slop) dismisses sticky :hover, matching Chrome. + UIWindow *window = _observedWindow; + if (window == nil || touch == nil) { + return; + } + CGPoint inWindow = [touch locationInView:window]; + CGPoint onScreen = [window convertPoint:inWindow toCoordinateSpace:window.screen.coordinateSpace]; + CGFloat dx = onScreen.x - _windowTouchStartScreen.x; + CGFloat dy = onScreen.y - _windowTouchStartScreen.y; + if (dx * dx + dy * dy > 100.0) { + [self clearAll]; + } +} + +- (void)observeTouchCancelled +{ + [self clearAll]; +} + +- (void)setEntry:(REATouchHoverEntry *)entry hovered:(BOOL)hovered +{ + if (entry->hovered == hovered) { + return; + } + entry->hovered = hovered; + if (entry->callback) { + entry->callback(hovered); + } +} + +- (void)recomputeAtScreenPoint:(CGPoint)onScreen +{ + NSMutableArray *dead = nil; + for (REATouchHoverEntry *entry in _entries) { + UIView *view = entry->view; + if (view == nil) { + [self setEntry:entry hovered:NO]; + if (dead == nil) { + dead = [NSMutableArray array]; + } + [dead addObject:entry]; + continue; + } + BOOL wantHover = NO; + if (view.window != nil && !view.hidden && view.alpha > 0.01) { + CGPoint local = [view convertPoint:onScreen fromCoordinateSpace:view.window.screen.coordinateSpace]; + wantHover = [view pointInside:local withEvent:nil]; + } + [self setEntry:entry hovered:wantHover]; + } + if (dead != nil) { + [_entries removeObjectsInArray:dead]; + if (_entries.count == 0) { + [self removeWindowObserver]; + } + } +} + +- (void)clearAll +{ + for (REATouchHoverEntry *entry in _entries) { + [self setEntry:entry hovered:NO]; + } +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer + shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer +{ + return YES; +} + +@end + +#endif From 2d0f0b5f2881cb879017f2b05835420b185121a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Wi=C5=9Bniewski?= Date: Tue, 23 Jun 2026 11:24:00 +0200 Subject: [PATCH 3/6] Mimic way visibility calculated on android --- .../pseudoSelectors/REATouchHoverCoordinator.mm | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm index eb35fa55980e..8fefbe98758c 100644 --- a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm +++ b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm @@ -191,6 +191,20 @@ - (void)setEntry:(REATouchHoverEntry *)entry hovered:(BOOL)hovered } } +// Visible only if the whole superview chain is, mirroring Android's View.isShown(). +- (BOOL)isViewVisibleOnScreen:(UIView *)view +{ + if (view.window == nil) { + return NO; + } + for (UIView *current = view; current != nil; current = current.superview) { + if (current.hidden || current.alpha <= 0.01) { + return NO; + } + } + return YES; +} + - (void)recomputeAtScreenPoint:(CGPoint)onScreen { NSMutableArray *dead = nil; @@ -205,7 +219,7 @@ - (void)recomputeAtScreenPoint:(CGPoint)onScreen continue; } BOOL wantHover = NO; - if (view.window != nil && !view.hidden && view.alpha > 0.01) { + if ([self isViewVisibleOnScreen:view]) { CGPoint local = [view convertPoint:onScreen fromCoordinateSpace:view.window.screen.coordinateSpace]; wantHover = [view pointInside:local withEvent:nil]; } From 87c5580f0a6f262e6d89aaf9b010266cc9e5bcb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Wed, 24 Jun 2026 03:58:50 +0200 Subject: [PATCH 4/6] Keep the touch :hover window observer passive across multi-touch The observer set its state to .failed on touch end/cancel. Because it never sets .began it never claims a touch, so failing was pointless and made UIKit stop delivering the rest of a multi-touch sequence to it (losing the scroll-clear slop and the new-finger recompute). Drop the .failed writes so it stays .possible. --- .../apple/pseudoSelectors/REATouchHoverCoordinator.mm | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm index 8fefbe98758c..0b6b0f2aa225 100644 --- a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm +++ b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm @@ -37,14 +37,9 @@ - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { [self.coordinator observeTouchMoved:touches.anyObject]; } -- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event -{ - self.state = UIGestureRecognizerStateFailed; -} - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { [self.coordinator observeTouchCancelled]; - self.state = UIGestureRecognizerStateFailed; } @end From f949cb45126afeda9331c0e1375b687398058aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Wed, 24 Jun 2026 04:45:59 +0200 Subject: [PATCH 5/6] Align touch :hover clear behavior across iOS and Android Two parity gaps in the sticky :hover clear paths, each present on one platform only: - iOS: removeWindowObserver did not clear hover, so when a modal/alert became the key window and the observer rebound, the old window's hover stayed stuck. Clear on teardown, mirroring Android. - Android: the window observer had no ACTION_MOVE handling, so a drag past touch slop that the per-view ACTION_CANCEL missed left sticky hover on. Clear on slop, mirroring iOS. Also dedupe the iOS purge-and-teardown into purgeEntries. --- .../pseudoSelectors/TouchHoverCoordinator.kt | 23 +++++++++++++++- .../REATouchHoverCoordinator.mm | 26 ++++++++++++------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt index 035ee16343e6..b49e6450980f 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.ContextWrapper import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import android.view.Window import com.facebook.react.bridge.ReactContext import com.swmansion.reanimated.nativeProxy.PseudoSelectorCallback @@ -113,11 +114,31 @@ class TouchHoverCoordinator { // The Activity (and its window) can be replaced; re-bind onto the live one. removeWindowObserver() val original = window.callback ?: return + val slop = ViewConfiguration.get(view.context).scaledTouchSlop.toFloat() val wrapper = object : Window.Callback by original { + private var startX = 0f + private var startY = 0f + private var slopExceeded = false + override fun dispatchTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> recompute(event.rawX, event.rawY) + MotionEvent.ACTION_DOWN -> { + startX = event.rawX + startY = event.rawY + slopExceeded = false + recompute(event.rawX, event.rawY) + } + // A scroll/drag past slop dismisses sticky :hover, matching iOS. + MotionEvent.ACTION_MOVE -> + if (!slopExceeded) { + val dx = event.rawX - startX + val dy = event.rawY - startY + if (dx * dx + dy * dy > slop * slop) { + slopExceeded = true + clearAll() + } + } MotionEvent.ACTION_CANCEL -> clearAll() } return original.dispatchTouchEvent(event) diff --git a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm index 0b6b0f2aa225..fe05335dfd83 100644 --- a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm +++ b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm @@ -91,10 +91,7 @@ - (void)unregisterObserver:(id)owner [removed addObject:entry]; } } - [_entries removeObjectsInArray:removed]; - if (_entries.count == 0) { - [self removeWindowObserver]; - } + [self purgeEntries:removed]; } - (UIWindow *)activeKeyWindow @@ -140,6 +137,20 @@ - (void)removeWindowObserver } _windowObserver = nil; _observedWindow = nil; + // Clear any sticky :hover left in the window we just stopped observing (e.g. when a modal/alert + // becomes key and we rebind), mirroring Android's removeWindowObserver. + [self clearAll]; +} + +- (void)purgeEntries:(NSArray *)batch +{ + if (batch.count == 0) { + return; + } + [_entries removeObjectsInArray:batch]; + if (_entries.count == 0) { + [self removeWindowObserver]; + } } - (void)observeTouchBegan:(UITouch *)touch @@ -220,12 +231,7 @@ - (void)recomputeAtScreenPoint:(CGPoint)onScreen } [self setEntry:entry hovered:wantHover]; } - if (dead != nil) { - [_entries removeObjectsInArray:dead]; - if (_entries.count == 0) { - [self removeWindowObserver]; - } - } + [self purgeEntries:dead]; } - (void)clearAll From 751df83a2edac468d9d3e00a72b856331e91752e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 25 Jun 2026 00:45:24 +0200 Subject: [PATCH 6/6] Name the iOS touch :hover slop constant --- .../apple/pseudoSelectors/REATouchHoverCoordinator.mm | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm index fe05335dfd83..50880972242b 100644 --- a/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm +++ b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm @@ -43,6 +43,8 @@ - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event } @end +static const CGFloat kTouchHoverSlop = 10.0; + @implementation REATouchHoverCoordinator { NSMutableArray *_entries; REAHoverTouchObserver *_windowObserver; @@ -176,7 +178,7 @@ - (void)observeTouchMoved:(UITouch *)touch CGPoint onScreen = [window convertPoint:inWindow toCoordinateSpace:window.screen.coordinateSpace]; CGFloat dx = onScreen.x - _windowTouchStartScreen.x; CGFloat dy = onScreen.y - _windowTouchStartScreen.y; - if (dx * dx + dy * dy > 100.0) { + if (dx * dx + dy * dy > kTouchHoverSlop * kTouchHoverSlop) { [self clearAll]; } }