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 553a48218cb3..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,12 +18,7 @@ class PseudoSelectorManager( private val touchListenerViews = HashSet() - private val hoverCallbacks = LinkedHashMap() - - private val hoveredViews = LinkedHashSet() - - // Reused by updateHoverStates to avoid allocating on every hover event (UI thread only). - private val hoverLocationBuffer = IntArray(2) + private val hover = TouchHoverCoordinator() fun attach( tag: Int, @@ -92,27 +87,12 @@ class PseudoSelectorManager( key: String, callback: PseudoSelectorCallback, ) { - // Android fires ACTION_HOVER_EXIT on an ancestor when the pointer moves onto a - // hoverable descendant, but CSS :hover must stay active there. So on enter/exit - // (not per-frame MOVE) recompute every registered view from the pointer position. - hoverCallbacks[view] = callback - val listener = - View.OnHoverListener { _, event -> - when (event.actionMasked) { - MotionEvent.ACTION_HOVER_ENTER, - MotionEvent.ACTION_HOVER_EXIT, - -> updateHoverStates(event.rawX, event.rawY) - } - false - } - view.setOnHoverListener(listener) + ensureTouchListener(view) + hover.register(view, callback) detachActions[key] = Runnable { - hoverCallbacks.remove(view) - if (hoveredViews.remove(view)) { - callback.onSelectorStateChanged(false) - } - view.setOnHoverListener(null) + hover.unregister(view) + maybeRemoveTouchListener(view) } } @@ -157,18 +137,24 @@ class PseudoSelectorManager( it.onSelectorStateChanged(true) } } + hover.recompute(event.rawX, event.rawY) } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + MotionEvent.ACTION_UP -> { fireActiveCallbacksUpTree(view, false) deepestCallbacks[view]?.onSelectorStateChanged(false) } + MotionEvent.ACTION_CANCEL -> { + fireActiveCallbacksUpTree(view, false) + deepestCallbacks[view]?.onSelectorStateChanged(false) + hover.clearAll() + } } false } } 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) } @@ -228,32 +214,6 @@ class PseudoSelectorManager( } } - /** - * Recompute every registered view's _:hover_ state from the pointer position, firing only on - * change. A view is hovered while the point is in its on-screen bounds - true for an ancestor - * while the pointer is over a descendant. Uses live (mid-animation) `getLocationOnScreen` bounds. - */ - private fun updateHoverStates( - rawX: Float, - rawY: Float, - ) { - val loc = hoverLocationBuffer - for ((view, callback) in hoverCallbacks) { - view.getLocationOnScreen(loc) - val contains = - rawX >= loc[0] && rawX <= loc[0] + view.width && - rawY >= loc[1] && rawY <= loc[1] + view.height - val wasHovered = hoveredViews.contains(view) - if (contains && !wasHovered) { - hoveredViews.add(view) - callback.onSelectorStateChanged(true) - } else if (!contains && wasHovered) { - hoveredViews.remove(view) - callback.onSelectorStateChanged(false) - } - } - } - private fun isDescendantOf( view: View, ancestor: View, 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..b49e6450980f --- /dev/null +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt @@ -0,0 +1,175 @@ +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.ViewConfiguration +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, + MotionEvent.ACTION_HOVER_EXIT, + -> recompute(event.rawX, event.rawY) + } + false + } + hoverCallbacks[view] = callback + ensureWindowObserver(view) + } + + fun unregister(view: View) { + view.setOnHoverListener(null) + val callback = hoverCallbacks.remove(view) + if (hoveredViews.remove(view)) { + callback?.onSelectorStateChanged(false) + } + 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 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 -> { + 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) + } + } + 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..50880972242b --- /dev/null +++ b/packages/react-native-reanimated/apple/reanimated/apple/pseudoSelectors/REATouchHoverCoordinator.mm @@ -0,0 +1,254 @@ +#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)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event +{ + [self.coordinator observeTouchCancelled]; +} +@end + +static const CGFloat kTouchHoverSlop = 10.0; + +@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]; + } + } + [self purgeEntries:removed]; +} + +- (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; + // 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 +{ + 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 > kTouchHoverSlop * kTouchHoverSlop) { + [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); + } +} + +// 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; + 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 ([self isViewVisibleOnScreen:view]) { + CGPoint local = [view convertPoint:onScreen fromCoordinateSpace:view.window.screen.coordinateSpace]; + wantHover = [view pointInside:local withEvent:nil]; + } + [self setEntry:entry hovered:wantHover]; + } + [self purgeEntries:dead]; +} + +- (void)clearAll +{ + for (REATouchHoverEntry *entry in _entries) { + [self setEntry:entry hovered:NO]; + } +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer + shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer +{ + return YES; +} + +@end + +#endif