Skip to content

Commit 88ecc3d

Browse files
MatiPl01wisniewskijclaude
authored
fix: Support :hover on touch devices (software-mansion#9720)
> Stacked on software-mansion#9729 (shared press touch listener). The diff here is just the `:hover` additions on top of it; review/merge software-mansion#9729 first. ## Summary `:hover` was a no-op on touchscreens (the pointer recognizers never fire for a finger). This makes it respond to touch with the **sticky** model the major browser engines use: a tapped view gains `:hover` and keeps it after the finger lifts, clearing only when a later touch lands elsewhere or on scroll. `:active` stays press-only and distinct; real-pointer hover (trackpad/mouse/stylus) is unchanged. Native-only change (the pseudo-state flow is selector-agnostic). On each touch-down a `:hover` view is hovered when its on-screen bounds contain the point. iOS uses a shared coordinator watching the key window through a passive, non-recognizing gesture recognizer (so it never competes with the `:active` recognizers); Android recomputes per view plus a `Window.Callback` observer to catch taps on blank space. > A `:hover` style needs a non-zero `transitionDuration` to apply (zero-duration is a pre-existing no-op). ## Demo Tap the box: it turns green and stays green after you lift; tap elsewhere to clear. <!-- drag hover-touch-android.gif here --> <!-- drag hover-touch-ios.gif here --> --------- Co-authored-by: Jakub Wiśniewski <wisniewskij514@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Jakub Wiśniewski <119816982+wisniewskij@users.noreply.github.com>
1 parent 5b1261f commit 88ecc3d

6 files changed

Lines changed: 485 additions & 83 deletions

File tree

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

Lines changed: 13 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,7 @@ class PseudoSelectorManager(
2323

2424
private val touchListenerViews = HashSet<View>()
2525

26-
private val hoverCallbacks = LinkedHashMap<View, PseudoSelectorCallback>()
27-
28-
private val hoveredViews = LinkedHashSet<View>()
29-
30-
// Reused by updateHoverStates to avoid allocating on every hover event (UI thread only).
31-
private val hoverLocationBuffer = IntArray(2)
26+
private val hover = TouchHoverCoordinator()
3227

3328
private val pendingAttaches = LinkedHashMap<String, PendingAttach>()
3429
private var mountListenerRegistered = false
@@ -161,27 +156,12 @@ class PseudoSelectorManager(
161156
key: String,
162157
callback: PseudoSelectorCallback,
163158
) {
164-
// Android fires ACTION_HOVER_EXIT on an ancestor when the pointer moves onto a
165-
// hoverable descendant, but CSS :hover must stay active there. So on enter/exit
166-
// (not per-frame MOVE) recompute every registered view from the pointer position.
167-
hoverCallbacks[view] = callback
168-
val listener =
169-
View.OnHoverListener { _, event ->
170-
when (event.actionMasked) {
171-
MotionEvent.ACTION_HOVER_ENTER,
172-
MotionEvent.ACTION_HOVER_EXIT,
173-
-> updateHoverStates(event.rawX, event.rawY)
174-
}
175-
false
176-
}
177-
view.setOnHoverListener(listener)
159+
ensureTouchListener(view)
160+
hover.register(view, callback)
178161
detachActions[key] =
179162
Runnable {
180-
hoverCallbacks.remove(view)
181-
if (hoveredViews.remove(view)) {
182-
callback.onSelectorStateChanged(false)
183-
}
184-
view.setOnHoverListener(null)
163+
hover.unregister(view)
164+
maybeRemoveTouchListener(view)
185165
}
186166
}
187167

@@ -226,18 +206,24 @@ class PseudoSelectorManager(
226206
it.onSelectorStateChanged(true)
227207
}
228208
}
209+
hover.recompute(event.rawX, event.rawY)
229210
}
230-
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
211+
MotionEvent.ACTION_UP -> {
231212
fireActiveCallbacksUpTree(view, false)
232213
deepestCallbacks[view]?.onSelectorStateChanged(false)
233214
}
215+
MotionEvent.ACTION_CANCEL -> {
216+
fireActiveCallbacksUpTree(view, false)
217+
deepestCallbacks[view]?.onSelectorStateChanged(false)
218+
hover.clearAll()
219+
}
234220
}
235221
false
236222
}
237223
}
238224

239225
private fun maybeRemoveTouchListener(view: View) {
240-
if (view !in activeCallbacks && view !in deepestCallbacks) {
226+
if (view !in activeCallbacks && view !in deepestCallbacks && !hover.isRegistered(view)) {
241227
touchListenerViews.remove(view)
242228
view.setOnTouchListener(null)
243229
}
@@ -301,32 +287,6 @@ class PseudoSelectorManager(
301287
}
302288
}
303289

304-
/**
305-
* Recompute every registered view's _:hover_ state from the pointer position, firing only on
306-
* change. A view is hovered while the point is in its on-screen bounds - true for an ancestor
307-
* while the pointer is over a descendant. Uses live (mid-animation) `getLocationOnScreen` bounds.
308-
*/
309-
private fun updateHoverStates(
310-
rawX: Float,
311-
rawY: Float,
312-
) {
313-
val loc = hoverLocationBuffer
314-
for ((view, callback) in hoverCallbacks) {
315-
view.getLocationOnScreen(loc)
316-
val contains =
317-
rawX >= loc[0] && rawX <= loc[0] + view.width &&
318-
rawY >= loc[1] && rawY <= loc[1] + view.height
319-
val wasHovered = hoveredViews.contains(view)
320-
if (contains && !wasHovered) {
321-
hoveredViews.add(view)
322-
callback.onSelectorStateChanged(true)
323-
} else if (!contains && wasHovered) {
324-
hoveredViews.remove(view)
325-
callback.onSelectorStateChanged(false)
326-
}
327-
}
328-
}
329-
330290
private fun isDescendantOf(
331291
view: View,
332292
ancestor: View,
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package com.swmansion.reanimated.pseudoSelectors
2+
3+
import android.app.Activity
4+
import android.content.Context
5+
import android.content.ContextWrapper
6+
import android.view.MotionEvent
7+
import android.view.View
8+
import android.view.ViewConfiguration
9+
import android.view.Window
10+
import com.facebook.react.bridge.ReactContext
11+
import com.swmansion.reanimated.nativeProxy.PseudoSelectorCallback
12+
import java.lang.ref.WeakReference
13+
14+
/**
15+
* Drives sticky touch :hover (Chromium model): a tapped view stays hovered after the finger lifts,
16+
* clearing only when a later touch lands elsewhere or a scroll cancels it. The hosting manager feeds
17+
* it touch-downs (per-view, plus a window observer for blank space). register also wires the pointer
18+
* (mouse/stylus) hover, which stays non-sticky.
19+
*/
20+
class TouchHoverCoordinator {
21+
private val hoverCallbacks = LinkedHashMap<View, PseudoSelectorCallback>()
22+
private val hoveredViews = LinkedHashSet<View>()
23+
private val tmpLocation = IntArray(2)
24+
25+
// Weak so a stale wrapper can never pin a destroyed Activity (this outlives Activities).
26+
private var observedWindow: WeakReference<Window>? = null
27+
private var originalWindowCallback: WeakReference<Window.Callback>? = null
28+
private var wrappedWindowCallback: WeakReference<Window.Callback>? = null
29+
30+
fun register(
31+
view: View,
32+
callback: PseudoSelectorCallback,
33+
) {
34+
view.setOnHoverListener { _, event ->
35+
when (event.actionMasked) {
36+
MotionEvent.ACTION_HOVER_ENTER,
37+
MotionEvent.ACTION_HOVER_EXIT,
38+
-> recompute(event.rawX, event.rawY)
39+
}
40+
false
41+
}
42+
hoverCallbacks[view] = callback
43+
ensureWindowObserver(view)
44+
}
45+
46+
fun unregister(view: View) {
47+
view.setOnHoverListener(null)
48+
val callback = hoverCallbacks.remove(view)
49+
if (hoveredViews.remove(view)) {
50+
callback?.onSelectorStateChanged(false)
51+
}
52+
if (hoverCallbacks.isEmpty()) {
53+
removeWindowObserver()
54+
}
55+
}
56+
57+
fun isRegistered(view: View) = view in hoverCallbacks
58+
59+
/** Turns :hover on for every registered view whose bounds contain the touch, off for the rest. */
60+
fun recompute(
61+
screenX: Float,
62+
screenY: Float,
63+
) {
64+
if (hoverCallbacks.isEmpty()) {
65+
return
66+
}
67+
for ((view, callback) in hoverCallbacks) {
68+
setHovered(view, callback, isPointInViewOnScreen(view, screenX, screenY))
69+
}
70+
}
71+
72+
fun clearAll() {
73+
if (hoveredViews.isEmpty()) {
74+
return
75+
}
76+
for (view in hoveredViews.toList()) {
77+
hoverCallbacks[view]?.let { setHovered(view, it, false) }
78+
}
79+
}
80+
81+
private fun setHovered(
82+
view: View,
83+
callback: PseudoSelectorCallback,
84+
hovered: Boolean,
85+
) {
86+
if ((view in hoveredViews) == hovered) {
87+
return
88+
}
89+
if (hovered) hoveredViews.add(view) else hoveredViews.remove(view)
90+
callback.onSelectorStateChanged(hovered)
91+
}
92+
93+
private fun isPointInViewOnScreen(
94+
view: View,
95+
screenX: Float,
96+
screenY: Float,
97+
): Boolean {
98+
if (!view.isShown) {
99+
return false
100+
}
101+
view.getLocationOnScreen(tmpLocation)
102+
val left = tmpLocation[0]
103+
val top = tmpLocation[1]
104+
return screenX >= left && screenX <= left + view.width &&
105+
screenY >= top && screenY <= top + view.height
106+
}
107+
108+
// Catches touch-downs on blank space (off any registered view), which per-view listeners miss.
109+
private fun ensureWindowObserver(view: View) {
110+
val window = view.activityWindow() ?: return
111+
if (observedWindow?.get() === window) {
112+
return
113+
}
114+
// The Activity (and its window) can be replaced; re-bind onto the live one.
115+
removeWindowObserver()
116+
val original = window.callback ?: return
117+
val slop = ViewConfiguration.get(view.context).scaledTouchSlop.toFloat()
118+
val wrapper =
119+
object : Window.Callback by original {
120+
private var startX = 0f
121+
private var startY = 0f
122+
private var slopExceeded = false
123+
124+
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
125+
when (event.actionMasked) {
126+
MotionEvent.ACTION_DOWN -> {
127+
startX = event.rawX
128+
startY = event.rawY
129+
slopExceeded = false
130+
recompute(event.rawX, event.rawY)
131+
}
132+
// A scroll/drag past slop dismisses sticky :hover, matching iOS.
133+
MotionEvent.ACTION_MOVE ->
134+
if (!slopExceeded) {
135+
val dx = event.rawX - startX
136+
val dy = event.rawY - startY
137+
if (dx * dx + dy * dy > slop * slop) {
138+
slopExceeded = true
139+
clearAll()
140+
}
141+
}
142+
MotionEvent.ACTION_CANCEL -> clearAll()
143+
}
144+
return original.dispatchTouchEvent(event)
145+
}
146+
}
147+
originalWindowCallback = WeakReference(original)
148+
wrappedWindowCallback = WeakReference(wrapper)
149+
observedWindow = WeakReference(window)
150+
window.callback = wrapper
151+
}
152+
153+
private fun removeWindowObserver() {
154+
val window = observedWindow?.get()
155+
val wrapper = wrappedWindowCallback?.get()
156+
// Restore only if our wrapper is still the live callback (nothing wrapped us afterwards).
157+
if (window != null && wrapper != null && window.callback === wrapper) {
158+
window.callback = originalWindowCallback?.get()
159+
}
160+
observedWindow = null
161+
originalWindowCallback = null
162+
wrappedWindowCallback = null
163+
clearAll()
164+
}
165+
166+
private fun View.activityWindow(): Window? {
167+
var ctx: Context? = context
168+
while (ctx is ContextWrapper) {
169+
if (ctx is Activity) return ctx.window
170+
if (ctx is ReactContext) ctx.currentActivity?.let { return it.window }
171+
ctx = ctx.baseContext
172+
}
173+
return (context as? ReactContext)?.currentActivity?.window
174+
}
175+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ NS_ASSUME_NONNULL_BEGIN
1919
* Focus - iOS: UITextField/UITextView editing notifications
2020
* macOS: NSWindow.firstResponder KVO observation
2121
* FocusWithin - Same as Focus, but matches any descendant gaining focus
22-
* Hover - iOS: UIHoverGestureRecognizer (iOS 13+)
22+
* Hover - iOS: UIHoverGestureRecognizer (real pointer) + a shared touch
23+
* coordinator for sticky touch hover (Chromium model)
2324
* macOS: NSTrackingArea (mouseEntered/mouseExited)
2425
* tvOS: no-op (UIHoverGestureRecognizer is unavailable);
2526
* the observer is constructed but never fires.

0 commit comments

Comments
 (0)