Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,7 @@ class PseudoSelectorManager(

private val touchListenerViews = HashSet<View>()

private val hoverCallbacks = LinkedHashMap<View, PseudoSelectorCallback>()

private val hoveredViews = LinkedHashSet<View>()

// 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,
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<View, PseudoSelectorCallback>()
private val hoveredViews = LinkedHashSet<View>()
private val tmpLocation = IntArray(2)

// Weak so a stale wrapper can never pin a destroyed Activity (this outlives Activities).
private var observedWindow: WeakReference<Window>? = null
private var originalWindowCallback: WeakReference<Window.Callback>? = null
private var wrappedWindowCallback: WeakReference<Window.Callback>? = 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading