Skip to content

Commit 9b8f83c

Browse files
authored
fix: Keep Android CSS :hover active while the pointer is over descendant views (#9718)
## Summary Android `:hover` toggled only the view that received the event, so moving the pointer onto a hoverable descendant fired `ACTION_HOVER_EXIT` on the ancestor and cleared its `:hover`. It's now recomputed from the pointer position on enter/exit, so an ancestor stays hovered while the pointer is over a descendant (matching `:active`). Also removes `elevation: 2` from the Showcase action buttons, which Android drew as an opaque white rectangle inside the rounded border. | Before | After | |-|-| | <video src="https://github.com/user-attachments/assets/bf11b5fa-fbee-46a3-b167-18f04e4ade54" /> | <video src="https://github.com/user-attachments/assets/910a6ef5-1f1c-4a3a-9b8e-92be702f7ea7" /> | ## Test plan Verified on a physical Samsung S26 Ultra using its S-pen.
1 parent 9551383 commit 9b8f83c

2 files changed

Lines changed: 49 additions & 7 deletions

File tree

  • apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors
  • packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors

apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Showcase.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,6 @@ const styles = StyleSheet.create({
155155
actionBtn: {
156156
borderRadius: radius.md,
157157
borderWidth: 1,
158-
elevation: 2,
159158
paddingHorizontal: spacing.md,
160159
paddingVertical: spacing.sm,
161160
shadowOffset: { height: 0, width: 0 },

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

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ class PseudoSelectorManager(
1717

1818
private val activeDeepestViews = LinkedHashSet<View>()
1919

20+
private val hoverCallbacks = LinkedHashMap<View, PseudoSelectorCallback>()
21+
22+
private val hoveredViews = LinkedHashSet<View>()
23+
24+
// Reused by updateHoverStates to avoid allocating on every hover event (UI thread only).
25+
private val hoverLocationBuffer = IntArray(2)
26+
2027
fun attach(
2128
tag: Int,
2229
selector: Int,
@@ -84,18 +91,28 @@ class PseudoSelectorManager(
8491
key: String,
8592
callback: PseudoSelectorCallback,
8693
) {
94+
// Android fires ACTION_HOVER_EXIT on an ancestor when the pointer moves onto a
95+
// hoverable descendant, but CSS :hover must stay active there. So on enter/exit
96+
// (not per-frame MOVE) recompute every registered view from the pointer position.
97+
hoverCallbacks[view] = callback
8798
val listener =
8899
View.OnHoverListener { _, event ->
89-
val action = event.actionMasked
90-
if (action == MotionEvent.ACTION_HOVER_ENTER) {
91-
callback.onSelectorStateChanged(true)
92-
} else if (action == MotionEvent.ACTION_HOVER_EXIT) {
93-
callback.onSelectorStateChanged(false)
100+
when (event.actionMasked) {
101+
MotionEvent.ACTION_HOVER_ENTER,
102+
MotionEvent.ACTION_HOVER_EXIT,
103+
-> updateHoverStates(event.rawX, event.rawY)
94104
}
95105
false
96106
}
97107
view.setOnHoverListener(listener)
98-
detachActions[key] = Runnable { view.setOnHoverListener(null) }
108+
detachActions[key] =
109+
Runnable {
110+
hoverCallbacks.remove(view)
111+
if (hoveredViews.remove(view)) {
112+
callback.onSelectorStateChanged(false)
113+
}
114+
view.setOnHoverListener(null)
115+
}
99116
}
100117

101118
private fun attachActive(
@@ -200,6 +217,32 @@ class PseudoSelectorManager(
200217
}
201218
}
202219

220+
/**
221+
* Recompute every registered view's _:hover_ state from the pointer position, firing only on
222+
* change. A view is hovered while the point is in its on-screen bounds - true for an ancestor
223+
* while the pointer is over a descendant. Uses live (mid-animation) `getLocationOnScreen` bounds.
224+
*/
225+
private fun updateHoverStates(
226+
rawX: Float,
227+
rawY: Float,
228+
) {
229+
val loc = hoverLocationBuffer
230+
for ((view, callback) in hoverCallbacks) {
231+
view.getLocationOnScreen(loc)
232+
val contains =
233+
rawX >= loc[0] && rawX <= loc[0] + view.width &&
234+
rawY >= loc[1] && rawY <= loc[1] + view.height
235+
val wasHovered = hoveredViews.contains(view)
236+
if (contains && !wasHovered) {
237+
hoveredViews.add(view)
238+
callback.onSelectorStateChanged(true)
239+
} else if (!contains && wasHovered) {
240+
hoveredViews.remove(view)
241+
callback.onSelectorStateChanged(false)
242+
}
243+
}
244+
}
245+
203246
private fun isDescendantOf(
204247
view: View,
205248
ancestor: View,

0 commit comments

Comments
 (0)