@@ -5,7 +5,6 @@ import android.content.Context
55import android.content.ContextWrapper
66import android.view.MotionEvent
77import android.view.View
8- import android.view.ViewConfiguration
98import android.view.ViewGroup
109import android.view.Window
1110import com.facebook.react.bridge.ReactContext
@@ -14,10 +13,12 @@ import com.swmansion.reanimated.nativeProxy.PseudoSelectorCallback
1413import java.lang.ref.WeakReference
1514
1615/* *
17- * Drives sticky touch :hover (Chromium model): a tapped view stays hovered after the finger lifts,
18- * clearing only when a later touch lands elsewhere or a scroll cancels it. The hosting manager feeds
19- * it touch-downs (per-view, plus a window observer for blank space). register also wires the pointer
20- * (mouse/stylus) hover, which stays non-sticky.
16+ * Drives sticky touch :hover: only the touch-down and release locations matter. A touch-down hovers
17+ * the views on its hit branch (and unhovers the rest, including on blank space), and when the first
18+ * finger lifts, a hovered view stays hovered only if the finger is still over it - moves and scrolls
19+ * in between change nothing. The Activity-window observer sees whole gestures; the hosting manager
20+ * feeds per-view touches as the fallback for windows the observer is blind to (Modal/Dialog).
21+ * register also wires the pointer (mouse/stylus) hover, which stays non-sticky.
2122 */
2223class TouchHoverCoordinator {
2324 private val hoverCallbacks = LinkedHashMap <View , PseudoSelectorCallback >()
@@ -30,6 +31,10 @@ class TouchHoverCoordinator {
3031 // prop, which for svg invalidates the front element's path so it resolves the one behind it.
3132 private var observedGestureDownTime = Long .MIN_VALUE
3233
34+ // downTime of the gesture whose release was already settled, so a later finger that inherits
35+ // pointer id 0 mid-gesture (or the per-view listener echoing the observer) cannot settle again.
36+ private var settledGestureDownTime = Long .MIN_VALUE
37+
3338 // Weak so a stale wrapper can never pin a destroyed Activity (this outlives Activities).
3439 private var observedWindow: WeakReference <Window >? = null
3540 private var originalWindowCallback: WeakReference <Window .Callback >? = null
@@ -84,12 +89,36 @@ class TouchHoverCoordinator {
8489 sourceView : View ,
8590 event : MotionEvent ,
8691 ) {
87- if (event.downTime == observedGestureDownTime) {
92+ if (event.downTime == observedGestureDownTime || isGestureSettled(event) ) {
8893 return
8994 }
9095 reconcile(sourceView.rootView as ? ViewGroup , event.rawX, event.rawY)
9196 }
9297
98+ // The Modal/Dialog counterparts of the observer's release handling. A cancel there ends the
99+ // gesture for good (the real release is never delivered), so the sticky :hover is dropped.
100+ fun onViewTouchUp (
101+ sourceView : View ,
102+ event : MotionEvent ,
103+ ) {
104+ if (event.downTime == observedGestureDownTime) {
105+ return
106+ }
107+ settleHover(sourceView.rootView as ? ViewGroup , event)
108+ }
109+
110+ fun onViewTouchCancel (event : MotionEvent ) {
111+ if (event.downTime == observedGestureDownTime || isGestureSettled(event)) {
112+ return
113+ }
114+ settledGestureDownTime = event.downTime
115+ clearAll()
116+ }
117+
118+ // Once a gesture's first finger released (or its cancel cleared), later fingers that inherit
119+ // pointer id 0 within the same gesture must stay ignored, web-like.
120+ fun isGestureSettled (event : MotionEvent ) = event.downTime == settledGestureDownTime
121+
93122 // Blank-space (window observer) path: no source view, so hit-test the observed window's tree.
94123 fun recompute (
95124 screenX : Float ,
@@ -112,6 +141,31 @@ class TouchHoverCoordinator {
112141 }
113142 }
114143
144+ // The first finger lifted: a hovered view stays hovered only if the finger is still over it.
145+ // Screen coords of the lifting pointer come from the index-0 raw/local delta (getRawX/Y(index)
146+ // needs API 29); nothing new is ever hovered here.
147+ private fun settleHover (
148+ root : ViewGroup ? ,
149+ event : MotionEvent ,
150+ ) {
151+ if (event.downTime == settledGestureDownTime) {
152+ return
153+ }
154+ settledGestureDownTime = event.downTime
155+ val index = event.findPointerIndex(0 )
156+ if (index < 0 || hoveredViews.isEmpty()) {
157+ return
158+ }
159+ val screenX = event.getX(index) + (event.rawX - event.getX(0 ))
160+ val screenY = event.getY(index) + (event.rawY - event.getY(0 ))
161+ val hitTags: Set <Int > = if (root == null ) emptySet() else hitTestPath(root, screenX, screenY)
162+ for (view in hoveredViews.toList()) {
163+ if (view.id !in hitTags) {
164+ hoverCallbacks[view]?.let { setHovered(view, it, false ) }
165+ }
166+ }
167+ }
168+
115169 // React tags on the hit branch (RN's hit-test honors z-order/transforms/clipping/pointer-events).
116170 // Matching by tag also covers svg's virtual children, whose tag rides the path with a null view.
117171 private fun hitTestPath (
@@ -132,7 +186,7 @@ class TouchHoverCoordinator {
132186 return window?.decorView as ? ViewGroup
133187 }
134188
135- fun clearAll () {
189+ private fun clearAll () {
136190 if (hoveredViews.isEmpty()) {
137191 return
138192 }
@@ -162,33 +216,25 @@ class TouchHoverCoordinator {
162216 // The Activity (and its window) can be replaced; re-bind onto the live one.
163217 removeWindowObserver()
164218 val original = window.callback ? : return
165- val slop = ViewConfiguration .get(view.context).scaledTouchSlop.toFloat()
166219 val wrapper =
167220 object : Window .Callback by original {
168- private var startX = 0f
169- private var startY = 0f
170- private var slopExceeded = false
171-
172221 override fun dispatchTouchEvent (event : MotionEvent ): Boolean {
173222 when (event.actionMasked) {
174223 MotionEvent .ACTION_DOWN -> {
175- startX = event.rawX
176- startY = event.rawY
177- slopExceeded = false
178224 observedGestureDownTime = event.downTime
179225 recompute(event.rawX, event.rawY)
180226 }
181- // A scroll/drag past slop dismisses sticky :hover, matching iOS.
182- MotionEvent .ACTION_MOVE ->
183- if (! slopExceeded) {
184- val dx = event.rawX - startX
185- val dy = event.rawY - startY
186- if (dx * dx + dy * dy > slop * slop) {
187- slopExceeded = true
188- clearAll()
189- }
227+ // Only the first finger's release settles the gesture; it can arrive as the
228+ // last-finger up or early, while other fingers stay down. A cancel is a system
229+ // interruption, not a release, so it keeps the :hover.
230+ MotionEvent .ACTION_UP ->
231+ if (event.findPointerIndex(0 ) >= 0 ) {
232+ settleHover(hoverRootViewGroup(), event)
233+ }
234+ MotionEvent .ACTION_POINTER_UP ->
235+ if (event.getPointerId(event.actionIndex) == 0 ) {
236+ settleHover(hoverRootViewGroup(), event)
190237 }
191- MotionEvent .ACTION_CANCEL -> clearAll()
192238 }
193239 return original.dispatchTouchEvent(event)
194240 }
0 commit comments