@@ -14,14 +14,22 @@ import com.swmansion.reanimated.nativeProxy.PseudoSelectorCallback
1414import java.lang.ref.WeakReference
1515
1616/* *
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.
17+ * Drives touch `:hover` to match the web (Chromium): a tap makes the touched view sticky-hovered, a
18+ * pan/scroll never changes the committed hover (it rolls back on release), and only the first finger
19+ * counts - the rest are ignored until it lifts.
20+ *
21+ * `committed` is the sticky hover a tap leaves behind; `displayed` is what is shown right now. Touch
22+ * events arrive from two places that feed the same single-primary-touch state machine: the window
23+ * observer (gestures anywhere in the Activity window, which it sees first and so claims) and the
24+ * per-view listener (gestures inside a Modal/Dialog, a separate window the observer is blind to).
25+ * Both forwarding the same Activity gesture is harmless - the guards keep it idempotent.
26+ *
27+ * register also wires the pointer (mouse/stylus) hover, which stays live and non-sticky.
2128 */
2229class TouchHoverCoordinator {
2330 private val hoverCallbacks = LinkedHashMap <View , PseudoSelectorCallback >()
24- private val hoveredViews = LinkedHashSet <View >()
31+ private val displayed = LinkedHashSet <View >()
32+ private val committed = LinkedHashSet <View >()
2533 private val tmpLocation = IntArray (2 )
2634 private val tmpCoords = FloatArray (2 )
2735
@@ -30,28 +38,56 @@ class TouchHoverCoordinator {
3038 private var originalWindowCallback: WeakReference <Window .Callback >? = null
3139 private var wrappedWindowCallback: WeakReference <Window .Callback >? = null
3240
41+ // The first finger of a gesture owns `:hover`; later fingers are ignored until it lifts.
42+ private var primaryActive = false
43+ private var primaryDownTime = 0L
44+ private var primaryPointerId = - 1
45+ private var downRawX = 0f
46+ private var downRawY = 0f
47+ private var panned = false
48+ private var downOnBlank = false
49+ private var rolledBackForScroll = false
50+ private var touchSlop = 0
51+
3352 fun register (
3453 view : View ,
3554 callback : PseudoSelectorCallback ,
3655 ) {
3756 view.setOnHoverListener { _, event ->
57+ // Real pointer (mouse/stylus) hover: live, non-sticky - keep committed in sync with it.
58+ // Ignored while a finger gesture owns the state, so a stray hover event can't corrupt the
59+ // in-flight gesture's rollback target.
3860 when (event.actionMasked) {
3961 MotionEvent .ACTION_HOVER_ENTER ,
62+ MotionEvent .ACTION_HOVER_MOVE ,
4063 MotionEvent .ACTION_HOVER_EXIT ,
41- -> recompute(view, event.rawX, event.rawY)
64+ ->
65+ if (! primaryActive) {
66+ displayHitPath(view.rootView as ? ViewGroup , event.rawX, event.rawY)
67+ committed.clear()
68+ committed.addAll(displayed)
69+ }
4270 }
4371 false
4472 }
4573 hoverCallbacks[view] = callback
74+ if (touchSlop == 0 ) {
75+ touchSlop = ViewConfiguration .get(view.context).scaledTouchSlop
76+ }
4677 ensureWindowObserver(view)
4778 }
4879
4980 fun unregister (view : View ) {
5081 view.setOnHoverListener(null )
5182 val callback = hoverCallbacks.remove(view)
52- if (hoveredViews.remove(view)) {
83+ committed.remove(view)
84+ if (displayed.remove(view)) {
5385 callback?.onSelectorStateChanged(false )
5486 }
87+ // Drop any in-flight gesture: a view torn down mid-press (e.g. a Modal driving the primary
88+ // touch via its own window) might never deliver the terminal up/cancel, which would otherwise
89+ // leave `primaryActive` stuck and silently ignore every later touch. The next touch re-claims.
90+ primaryActive = false
5591 if (hoverCallbacks.isEmpty()) {
5692 removeWindowObserver()
5793 }
@@ -60,38 +96,113 @@ class TouchHoverCoordinator {
6096 fun isRegistered (view : View ) = view in hoverCallbacks
6197
6298 /* *
63- * Mirrors CSS hit-testing: turns :hover on for the topmost view at the touch and its registered
64- * ancestors, off for the rest. Views that merely overlap the hit branch (which a plain bounds
65- * test would all activate) stay off, because only the hit branch is hovered. Rooted on the
66- * touched view's own window so it works inside a Modal/Dialog (a separate window from the Activity).
99+ * Single entry point for the touch model, fed by both the per-view listener (passing the touched
100+ * view's root, so Modal/Dialog windows work) and the window observer (passing the decor view).
101+ * Only ACTION_DOWN/MOVE/UP/CANCEL of the first finger matter.
67102 */
68- fun recompute (
69- sourceView : View ,
70- screenX : Float ,
71- screenY : Float ,
103+ fun onTouchEvent (
104+ event : MotionEvent ,
105+ root : ViewGroup ? ,
72106 ) {
73- reconcile(sourceView.rootView as ? ViewGroup , screenX, screenY)
107+ when (event.actionMasked) {
108+ MotionEvent .ACTION_DOWN -> onPrimaryDown(event, root)
109+ MotionEvent .ACTION_MOVE -> onPrimaryMove(event)
110+ MotionEvent .ACTION_POINTER_UP ->
111+ if (event.getPointerId(event.actionIndex) == primaryPointerId) {
112+ onPrimaryEnd(event)
113+ }
114+ MotionEvent .ACTION_UP -> onPrimaryEnd(event)
115+ MotionEvent .ACTION_CANCEL -> onPrimaryCancel(event)
116+ }
74117 }
75118
76- // Blank-space (window observer) path: no source view, so hit-test the observed window's tree.
77- fun recompute (
78- screenX : Float ,
79- screenY : Float ,
119+ private fun onPrimaryDown (
120+ event : MotionEvent ,
121+ root : ViewGroup ? ,
80122 ) {
81- reconcile(hoverRootViewGroup(), screenX, screenY)
123+ if (primaryActive || hoverCallbacks.isEmpty()) {
124+ return
125+ }
126+ primaryActive = true
127+ primaryDownTime = event.downTime
128+ primaryPointerId = event.getPointerId(0 )
129+ downRawX = event.rawX
130+ downRawY = event.rawY
131+ panned = false
132+ rolledBackForScroll = false
133+ // Reconcile the live hover to the touched branch: press feedback on it, previous hover
134+ // cleared right away (restored on release if this turns into a pan/scroll).
135+ displayHitPath(root, event.rawX, event.rawY)
136+ // Empty hit-path == the gesture began off every hover view (blank space / a scroll surface).
137+ downOnBlank = displayed.isEmpty()
138+ }
139+
140+ private fun onPrimaryMove (event : MotionEvent ) {
141+ if (! primaryActive || event.downTime != primaryDownTime) {
142+ return
143+ }
144+ // event.rawX/rawY track the first finger (pointer index 0) for the whole gesture.
145+ if (movedPastSlop(event)) {
146+ // A drag/scroll: keep showing the down branch (so hover stays while the finger is over
147+ // the view) but mark it so the release rolls back to the committed hover instead of
148+ // committing this branch.
149+ panned = true
150+ }
151+ // A drag that began off every hover view (typically a scroll) can never commit a new branch,
152+ // so roll back to the committed hover right away instead of waiting for release. This keeps a
153+ // sticky-hovered view visible while scrolling from blank space (matching iOS, where the
154+ // enclosing scroll view's drag is detected). A drag that began on a hover view is left alone:
155+ // its release decides, and a scroll over it is rolled back by the per-view touch-cancel.
156+ if (panned && downOnBlank && ! rolledBackForScroll) {
157+ rolledBackForScroll = true
158+ revertToCommitted()
159+ }
160+ }
161+
162+ // ACTION_UP always carries the final position, so deciding tap-vs-pan from the lift point is
163+ // robust even when a scrolling child swallows the intervening ACTION_MOVE events at the window.
164+ private fun movedPastSlop (event : MotionEvent ): Boolean {
165+ val dx = event.rawX - downRawX
166+ val dy = event.rawY - downRawY
167+ return dx * dx + dy * dy > touchSlop.toFloat() * touchSlop
82168 }
83169
84- private fun reconcile (
170+ private fun onPrimaryEnd (event : MotionEvent ) {
171+ if (! primaryActive || event.downTime != primaryDownTime) {
172+ return
173+ }
174+ if (movedPastSlop(event)) {
175+ panned = true
176+ }
177+ primaryActive = false
178+ if (panned) {
179+ revertToCommitted() // A pan/scroll never changes the sticky hover.
180+ } else {
181+ commitDisplayed() // A tap makes the touched branch sticky.
182+ }
183+ }
184+
185+ private fun onPrimaryCancel (event : MotionEvent ) {
186+ if (! primaryActive || event.downTime != primaryDownTime) {
187+ return
188+ }
189+ primaryActive = false
190+ revertToCommitted()
191+ }
192+
193+ /* *
194+ * Mirrors CSS hit-testing: turns hover on for the topmost view at the point and its registered
195+ * ancestors, off for the rest. Views that merely overlap the hit branch (which a plain bounds
196+ * test would all activate) stay off, because only the hit branch is hovered.
197+ */
198+ private fun displayHitPath (
85199 root : ViewGroup ? ,
86200 screenX : Float ,
87201 screenY : Float ,
88202 ) {
89- if (hoverCallbacks.isEmpty()) {
90- return
91- }
92203 val hitPath: Set <View > = if (root == null ) emptySet() else hitTestPath(root, screenX, screenY)
93204 for ((view, callback) in hoverCallbacks) {
94- setHovered (view, callback, view in hitPath)
205+ setDisplayed (view, callback, view in hitPath)
95206 }
96207 }
97208
@@ -114,33 +225,38 @@ class TouchHoverCoordinator {
114225 return views
115226 }
116227
117- private fun hoverRootViewGroup (): ViewGroup ? {
118- val window = observedWindow?.get() ? : hoverCallbacks.keys.firstOrNull()?.activityWindow()
119- return window?.decorView as ? ViewGroup
120- }
121-
122228 fun clearAll () {
123- if (hoveredViews.isEmpty() ) {
124- return
229+ for ((view, callback) in hoverCallbacks ) {
230+ setDisplayed(view, callback, false )
125231 }
126- for (view in hoveredViews.toList()) {
127- hoverCallbacks[view]?.let { setHovered(view, it, false ) }
232+ committed.clear()
233+ }
234+
235+ private fun commitDisplayed () {
236+ committed.clear()
237+ committed.addAll(displayed)
238+ }
239+
240+ private fun revertToCommitted () {
241+ for ((view, callback) in hoverCallbacks) {
242+ setDisplayed(view, callback, view in committed)
128243 }
129244 }
130245
131- private fun setHovered (
246+ private fun setDisplayed (
132247 view : View ,
133248 callback : PseudoSelectorCallback ,
134249 hovered : Boolean ,
135250 ) {
136- if ((view in hoveredViews ) == hovered) {
251+ if ((view in displayed ) == hovered) {
137252 return
138253 }
139- if (hovered) hoveredViews .add(view) else hoveredViews .remove(view)
254+ if (hovered) displayed .add(view) else displayed .remove(view)
140255 callback.onSelectorStateChanged(hovered)
141256 }
142257
143- // Catches touch-downs on blank space (off any registered view), which per-view listeners miss.
258+ // Catches gestures on blank space (off any registered view), which per-view listeners miss, and
259+ // drives every Activity-window gesture (it sees them before the per-view listeners).
144260 private fun ensureWindowObserver (view : View ) {
145261 val window = view.activityWindow() ? : return
146262 if (observedWindow?.get() == = window) {
@@ -149,33 +265,10 @@ class TouchHoverCoordinator {
149265 // The Activity (and its window) can be replaced; re-bind onto the live one.
150266 removeWindowObserver()
151267 val original = window.callback ? : return
152- val slop = ViewConfiguration .get(view.context).scaledTouchSlop.toFloat()
153268 val wrapper =
154269 object : Window .Callback by original {
155- private var startX = 0f
156- private var startY = 0f
157- private var slopExceeded = false
158-
159270 override fun dispatchTouchEvent (event : MotionEvent ): Boolean {
160- when (event.actionMasked) {
161- MotionEvent .ACTION_DOWN -> {
162- startX = event.rawX
163- startY = event.rawY
164- slopExceeded = false
165- recompute(event.rawX, event.rawY)
166- }
167- // A scroll/drag past slop dismisses sticky :hover, matching iOS.
168- MotionEvent .ACTION_MOVE ->
169- if (! slopExceeded) {
170- val dx = event.rawX - startX
171- val dy = event.rawY - startY
172- if (dx * dx + dy * dy > slop * slop) {
173- slopExceeded = true
174- clearAll()
175- }
176- }
177- MotionEvent .ACTION_CANCEL -> clearAll()
178- }
271+ onTouchEvent(event, window.decorView as ? ViewGroup )
179272 return original.dispatchTouchEvent(event)
180273 }
181274 }
@@ -195,6 +288,7 @@ class TouchHoverCoordinator {
195288 observedWindow = null
196289 originalWindowCallback = null
197290 wrappedWindowCallback = null
291+ primaryActive = false
198292 clearAll()
199293 }
200294
0 commit comments