diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt index 586b8351674..91b7cdebd75 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt @@ -134,6 +134,7 @@ open class NativeProxy { if (mInvalidated.getAndSet(true)) { return } + pseudoSelectorManager.invalidate() if (mHybridData.isValid) { invalidateCpp() } diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt index 5b07281f833..2f18d031a0f 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/PseudoSelectorManager.kt @@ -4,6 +4,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewParent +import android.view.ViewTreeObserver import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.UIManager import com.facebook.react.bridge.UIManagerListener @@ -27,17 +28,14 @@ class PseudoSelectorManager( private val deepestCallbacks = LinkedHashMap() private val touchHostRefs = HashMap() - - private val gestureDownLeaf = HashMap() - - private val gestureDownPoint = HashMap() + private val gestureByHost = HashMap() private val hover = TouchHoverCoordinator() - private var extraWindowBridge: ExtraWindowObserverBridge? = null private val pendingAttaches = LinkedHashMap() private var mountListenerRegistered = false + private var pressWindowInterceptorSet = false private data class PendingAttach( val tag: Int, @@ -45,6 +43,12 @@ class PseudoSelectorManager( val callback: PseudoSelectorCallback, ) + private class HostGesture( + val leaf: View, + val downX: Float, + val downY: Float, + ) + private val mountListener = object : UIManagerListener { override fun didMountItems(uiManager: UIManager) = flushPendingAttaches() @@ -82,11 +86,11 @@ class PseudoSelectorManager( ) { val key = "$tag:$selector" when (selector) { - 0 -> attachFocusWithin(view, key, callback) - 1 -> attachFocus(view, key, callback) + 0 -> attachFocusListener(view, key, callback) { view.hasFocus() } + 1 -> attachFocusListener(view, key, callback) { it === view } 2 -> attachHover(view, key, callback) - 3 -> attachActive(view, key, callback) - 4 -> attachActiveDeepest(view, key, callback) + 3 -> attachPressSelector(view, key, callback, activeCallbacks) + 4 -> attachPressSelector(view, key, callback, deepestCallbacks) } } @@ -118,43 +122,19 @@ class PseudoSelectorManager( } } - private fun attachFocusWithin( - view: View, - key: String, - callback: PseudoSelectorCallback, - ) { - var isFocused = false - val listener = - android.view.ViewTreeObserver.OnGlobalFocusChangeListener { _, _ -> - val hasFocus = view.hasFocus() - if (hasFocus && !isFocused) { - isFocused = true - callback.onSelectorStateChanged(true) - } else if (!hasFocus && isFocused) { - isFocused = false - callback.onSelectorStateChanged(false) - } - } - view.viewTreeObserver.addOnGlobalFocusChangeListener(listener) - detachActions[key] = - Runnable { view.viewTreeObserver.removeOnGlobalFocusChangeListener(listener) } - } - - private fun attachFocus( + private fun attachFocusListener( view: View, key: String, callback: PseudoSelectorCallback, + isFocused: (newFocus: View?) -> Boolean, ) { - var isFocused = false + var focused = false val listener = - android.view.ViewTreeObserver.OnGlobalFocusChangeListener { _, newFocus -> - val directFocus = newFocus == view - if (directFocus && !isFocused) { - isFocused = true - callback.onSelectorStateChanged(true) - } else if (!directFocus && isFocused) { - isFocused = false - callback.onSelectorStateChanged(false) + ViewTreeObserver.OnGlobalFocusChangeListener { _, newFocus -> + val nowFocused = isFocused(newFocus) + if (nowFocused != focused) { + focused = nowFocused + callback.onSelectorStateChanged(nowFocused) } } view.viewTreeObserver.addOnGlobalFocusChangeListener(listener) @@ -169,11 +149,11 @@ class PseudoSelectorManager( ) { val host = findTouchHost(view) acquireTouchListener(host) - hover.register(view, callback) + hover.register(view, host, callback) ensureExtraWindowBridge() detachActions[key] = Runnable { - hover.unregister(view) + hover.unregister(view, host) releaseTouchListener(host) } } @@ -186,57 +166,127 @@ class PseudoSelectorManager( extraWindowBridge = ExtraWindowObserverBridge(context, hover).also { it.install() } } - private fun attachActive( + private fun attachPressSelector( view: View, key: String, callback: PseudoSelectorCallback, + callbacks: MutableMap, ) { val host = findTouchHost(view) - activeCallbacks[view] = callback + callbacks[view] = callback acquireTouchListener(host) + hover.retainWindowObserver(view) + ensureExtraWindowBridge() + ensurePressWindowInterceptor() detachActions[key] = Runnable { - activeCallbacks.remove(view) + callbacks.remove(view) releaseTouchListener(host) + hover.releaseWindowObserver() } } - private fun attachActiveDeepest( - view: View, - key: String, - callback: PseudoSelectorCallback, - ) { - val host = findTouchHost(view) - deepestCallbacks[view] = callback - acquireTouchListener(host) - detachActions[key] = - Runnable { - deepestCallbacks.remove(view) - releaseTouchListener(host) - } + private fun ensurePressWindowInterceptor() { + if (pressWindowInterceptorSet) { + return + } + pressWindowInterceptorSet = true + hover.setWindowTouchInterceptor(::onWindowPressTouch) } - private fun findTouchHost(view: View): View { - var parent: ViewParent? = view.parent - while (parent is View) { - if (parent is ReactCompoundView) { - return parent + private fun onWindowPressTouch(event: MotionEvent) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> windowPressDown(event) + MotionEvent.ACTION_MOVE -> + if (event.findPointerIndex(0) >= 0) { + for (host in gestureByHost.keys.toList()) { + onHostMove(host, event) + } + } + MotionEvent.ACTION_UP -> + if (event.findPointerIndex(0) >= 0) { + releaseAllHostGestures() + } + MotionEvent.ACTION_POINTER_UP -> + if (event.getPointerId(event.actionIndex) == 0) { + releaseAllHostGestures() + } + MotionEvent.ACTION_CANCEL -> + releaseAllHostGestures() + } + } + + private fun windowPressDown(event: MotionEvent) { + if (activeCallbacks.isEmpty() && deepestCallbacks.isEmpty()) { + return + } + for (host in pressHosts()) { + if (gestureByHost.containsKey(host) || !viewContainsScreenPoint(host, event.rawX, event.rawY)) { + continue } - parent = parent.parent + val leaf = findTouchedLeaf(host, event.rawX, event.rawY) ?: continue + beginPress(host, leaf, event.rawX, event.rawY) } - return view + } + + private fun beginPress( + host: View, + leaf: View, + rawX: Float, + rawY: Float, + ) { + gestureByHost[host] = HostGesture(leaf, rawX, rawY) + fireActiveCallbacksUpTree(leaf, true) + fireDeepestIfHit(leaf, rawX, rawY) + } + + private fun pressHosts(): Set { + val hosts = LinkedHashSet() + activeCallbacks.keys.forEach { hosts.add(findTouchHost(it)) } + deepestCallbacks.keys.forEach { hosts.add(findTouchHost(it)) } + return hosts + } + + private fun viewContainsScreenPoint( + view: View, + rawX: Float, + rawY: Float, + ): Boolean { + val loc = IntArray(2) + view.getLocationOnScreen(loc) + return rawX >= loc[0] && rawX <= loc[0] + view.width && rawY >= loc[1] && rawY <= loc[1] + view.height } private fun findTouchedLeaf( host: View, - event: MotionEvent, + rawX: Float, + rawY: Float, ): View? = if (host is ReactCompoundView) { - tryResolveView(host.reactTagForTouch(event.x, event.y)) + val loc = IntArray(2) + host.getLocationOnScreen(loc) + tryResolveView(host.reactTagForTouch(rawX - loc[0], rawY - loc[1])) } else { host } + private fun releaseAllHostGestures() { + for (host in gestureByHost.keys.toList()) { + onHostRelease(host) + } + } + + private fun findTouchHost(view: View): View { + var parent: ViewParent? = view.parent + while (parent is View) { + if (parent is ReactCompoundView) { + return parent + } + parent = parent.parent + } + return view + } + private fun acquireTouchListener(host: View) { val count = touchHostRefs.getOrDefault(host, 0) touchHostRefs[host] = count + 1 @@ -252,8 +302,7 @@ class PseudoSelectorManager( return } touchHostRefs.remove(host) - gestureDownLeaf.remove(host) - gestureDownPoint.remove(host) + gestureByHost.remove(host) host.setOnTouchListener(null) } @@ -261,6 +310,9 @@ class PseudoSelectorManager( host: View, event: MotionEvent, ): Boolean { + if (hover.isWindowObserved(host)) { + return false + } when (event.actionMasked) { MotionEvent.ACTION_DOWN -> if (!hover.isGestureSettled(event)) { @@ -293,11 +345,8 @@ class PseudoSelectorManager( host: View, event: MotionEvent, ) { - findTouchedLeaf(host, event)?.let { - gestureDownLeaf[host] = it - gestureDownPoint[host] = floatArrayOf(event.rawX, event.rawY) - fireActiveCallbacksUpTree(it, true) - fireDeepestIfHit(it, event.rawX, event.rawY) + findTouchedLeaf(host, event.rawX, event.rawY)?.let { + beginPress(host, it, event.rawX, event.rawY) } hover.onViewTouchDown(host, event) } @@ -306,9 +355,9 @@ class PseudoSelectorManager( host: View, event: MotionEvent, ) { - val down = gestureDownPoint[host] ?: return - val dx = event.rawX - down[0] - val dy = event.rawY - down[1] + val gesture = gestureByHost[host] ?: return + val dx = event.rawX - gesture.downX + val dy = event.rawY - gesture.downY val slop = ViewConfiguration.get(host.context).scaledTouchSlop.toFloat() if (dx * dx + dy * dy > slop * slop) { onHostRelease(host) @@ -316,8 +365,7 @@ class PseudoSelectorManager( } private fun onHostRelease(host: View) { - gestureDownPoint.remove(host) - val leaf = gestureDownLeaf.remove(host) ?: return + val leaf = gestureByHost.remove(host)?.leaf ?: return fireActiveCallbacksUpTree(leaf, false) deepestCallbacks[leaf]?.onSelectorStateChanged(false) } @@ -344,6 +392,17 @@ class PseudoSelectorManager( } } + fun invalidate() { + UiThreadUtil.runOnUiThread { + if (mountListenerRegistered) { + fabricUIManager.removeUIManagerEventListener(mountListener) + mountListenerRegistered = false + } + extraWindowBridge?.uninstall() + extraWindowBridge = null + } + } + private fun hasDeepestDescendantAt( ancestor: View, rawX: Float, @@ -352,15 +411,11 @@ class PseudoSelectorManager( if (deepestCallbacks.size < 2) { return false } - val loc = IntArray(2) // TODO: Optimize so we don't iterate over all the views with :active-deepest every time. for (candidate in deepestCallbacks.keys) { if (candidate === ancestor) continue if (!isDescendantOf(candidate, ancestor)) continue - candidate.getLocationOnScreen(loc) - if (rawX >= loc[0] && rawX <= loc[0] + candidate.width && - rawY >= loc[1] && rawY <= loc[1] + candidate.height - ) { + if (viewContainsScreenPoint(candidate, rawX, rawY)) { return true } } diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt index 74d7cfa6be1..3f8f0746e61 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/pseudoSelectors/TouchHoverCoordinator.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.ContextWrapper import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import android.view.ViewGroup import android.view.Window import com.facebook.react.bridge.ReactContext @@ -15,59 +16,116 @@ import java.lang.ref.WeakReference class TouchHoverCoordinator { private val hoverCallbacks = LinkedHashMap() private val hoveredViews = LinkedHashSet() + private val hoverHostRefs = HashMap() private val tmpLocation = IntArray(2) private val tmpCoords = FloatArray(2) private var settledGestureDownTime = Long.MIN_VALUE + private var touchSlop = -1f + + private var pointerInsideRegistered = false + private var lastPointerX = 0f + private var lastPointerY = 0f private val observedWindows = mutableListOf>() + private var windowObserverRetainCount = 0 + + private var windowTouchInterceptor: ((MotionEvent) -> Unit)? = null + private inner class WindowObserver( window: Window, val original: Window.Callback, ) : Window.Callback by original { val windowRef = WeakReference(window) + private val downPoint = FloatArray(2) override fun dispatchTouchEvent(event: MotionEvent): Boolean { val root = windowRef.get()?.decorView as? ViewGroup when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> reconcile(root, event.rawX, event.rawY) + MotionEvent.ACTION_DOWN -> { + downPoint[0] = event.rawX + downPoint[1] = event.rawY + pointerInsideRegistered = false + reconcile(root, event.rawX, event.rawY) + } MotionEvent.ACTION_UP -> - if (event.findPointerIndex(0) >= 0) settleHover(root, event) + if (event.findPointerIndex(0) >= 0) settleHover(root, event, downPoint) MotionEvent.ACTION_POINTER_UP -> - if (event.getPointerId(event.actionIndex) == 0) settleHover(root, event) + if (event.getPointerId(event.actionIndex) == 0) settleHover(root, event, downPoint) } + windowTouchInterceptor?.invoke(event) return original.dispatchTouchEvent(event) } } fun register( view: View, + host: View, callback: PseudoSelectorCallback, ) { - view.setOnHoverListener { _, event -> - when (event.actionMasked) { - MotionEvent.ACTION_HOVER_ENTER, - MotionEvent.ACTION_HOVER_EXIT, - -> recompute(view, event.rawX, event.rawY) - } - false - } hoverCallbacks[view] = callback - ensureWindowObserver(view) + acquireHoverListener(host) + retainWindowObserver(view) } - fun unregister(view: View) { - view.setOnHoverListener(null) + fun unregister( + view: View, + host: View, + ) { val callback = hoverCallbacks.remove(view) if (hoveredViews.remove(view)) { callback?.onSelectorStateChanged(false) } - if (hoverCallbacks.isEmpty()) { + releaseHoverListener(host) + releaseWindowObserver() + } + + fun setWindowTouchInterceptor(interceptor: (MotionEvent) -> Unit) { + windowTouchInterceptor = interceptor + } + + fun retainWindowObserver(view: View) { + windowObserverRetainCount++ + ensureWindowObserver(view) + } + + fun releaseWindowObserver() { + if (windowObserverRetainCount == 0) { + return + } + windowObserverRetainCount-- + if (windowObserverRetainCount == 0) { removeAllWindowObservers() } } + private fun acquireHoverListener(host: View) { + val count = hoverHostRefs.getOrDefault(host, 0) + hoverHostRefs[host] = count + 1 + if (count == 0) { + host.setOnHoverListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_HOVER_ENTER, + MotionEvent.ACTION_HOVER_MOVE, + -> recompute(host, event.rawX, event.rawY) + MotionEvent.ACTION_HOVER_EXIT -> onPointerExit() + } + false + } + } + } + + private fun releaseHoverListener(host: View) { + val count = hoverHostRefs.getOrDefault(host, 0) + if (count > 1) { + hoverHostRefs[host] = count - 1 + return + } + hoverHostRefs.remove(host) + host.setOnHoverListener(null) + } + fun observeExtraWindow(window: Window) { installObserverOnWindow(window) } @@ -82,7 +140,40 @@ class TouchHoverCoordinator { screenX: Float, screenY: Float, ) { - reconcile(sourceView.rootView as? ViewGroup, screenX, screenY) + val root = sourceView.rootView as? ViewGroup ?: return + if (hoverCallbacks.isEmpty()) { + return + } + val hitTags = hitTestPath(root, screenX, screenY) + val overRegistered = hoverCallbacks.keys.any { it.id in hitTags } + if (overRegistered) { + if (!pointerInsideRegistered || movedBeyondSlop(root, screenX, screenY)) { + applyHover(hitTags) + lastPointerX = screenX + lastPointerY = screenY + } + } else if (pointerInsideRegistered) { + applyHover(hitTags) + } + pointerInsideRegistered = overRegistered + } + + private fun onPointerExit() { + if (pointerInsideRegistered) { + applyHover(emptySet()) + } + pointerInsideRegistered = false + } + + private fun movedBeyondSlop( + root: ViewGroup?, + screenX: Float, + screenY: Float, + ): Boolean { + val dx = screenX - lastPointerX + val dy = screenY - lastPointerY + val slop = scaledTouchSlop(root) + return dx * dx + dy * dy > slop * slop } fun onViewTouchDown( @@ -92,6 +183,7 @@ class TouchHoverCoordinator { if (isWindowObserved(sourceView) || isGestureSettled(event)) { return } + pointerInsideRegistered = false reconcile(sourceView.rootView as? ViewGroup, event.rawX, event.rawY) } @@ -102,7 +194,7 @@ class TouchHoverCoordinator { if (isWindowObserved(sourceView)) { return } - settleHover(sourceView.rootView as? ViewGroup, event) + settleHover(sourceView.rootView as? ViewGroup, event, null) } fun onViewTouchCancel( @@ -126,7 +218,10 @@ class TouchHoverCoordinator { if (hoverCallbacks.isEmpty()) { return } - val hitTags: Set = if (root == null) emptySet() else hitTestPath(root, screenX, screenY) + applyHover(if (root == null) emptySet() else hitTestPath(root, screenX, screenY)) + } + + private fun applyHover(hitTags: Set) { for ((view, callback) in hoverCallbacks) { setHovered(view, callback, view.id in hitTags) } @@ -135,8 +230,9 @@ class TouchHoverCoordinator { private fun settleHover( root: ViewGroup?, event: MotionEvent, + downPoint: FloatArray?, ) { - if (event.downTime == settledGestureDownTime) { + if (isGestureSettled(event)) { return } settledGestureDownTime = event.downTime @@ -146,12 +242,31 @@ class TouchHoverCoordinator { } val screenX = event.getX(index) + (event.rawX - event.getX(0)) val screenY = event.getY(index) + (event.rawY - event.getY(0)) + if (downPoint != null && isStationary(screenX, screenY, downPoint, root)) { + return + } val hitTags: Set = if (root == null) emptySet() else hitTestPath(root, screenX, screenY) - for (view in hoveredViews.toList()) { - if (view.id !in hitTags) { - hoverCallbacks[view]?.let { setHovered(view, it, false) } - } + unhoverWhere { it.id !in hitTags } + } + + private fun isStationary( + x: Float, + y: Float, + downPoint: FloatArray, + root: ViewGroup?, + ): Boolean { + val dx = x - downPoint[0] + val dy = y - downPoint[1] + val slop = scaledTouchSlop(root) + return dx * dx + dy * dy <= slop * slop + } + + private fun scaledTouchSlop(root: ViewGroup?): Float { + if (touchSlop < 0f) { + val context = root?.context ?: hoverCallbacks.keys.firstOrNull()?.context ?: return 0f + touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat() } + return touchSlop } private fun hitTestPath( @@ -167,25 +282,25 @@ class TouchHoverCoordinator { return targets.mapTo(HashSet(targets.size)) { it.getViewId() } } - private fun clearAll() { + private inline fun unhoverWhere(predicate: (View) -> Boolean) { if (hoveredViews.isEmpty()) { return } for (view in hoveredViews.toList()) { - hoverCallbacks[view]?.let { setHovered(view, it, false) } + if (predicate(view)) { + hoverCallbacks[view]?.let { setHovered(view, it, false) } + } } } + private fun clearAll() { + pointerInsideRegistered = false + unhoverWhere { true } + } + private fun clearHoverForWindow(window: Window) { - if (hoveredViews.isEmpty()) { - return - } val decor = window.decorView - for (view in hoveredViews.toList()) { - if (view.rootView === decor) { - hoverCallbacks[view]?.let { setHovered(view, it, false) } - } - } + unhoverWhere { it.rootView === decor } } private fun setHovered( @@ -200,14 +315,16 @@ class TouchHoverCoordinator { callback.onSelectorStateChanged(hovered) } + private fun WeakReference.liveWindow(): Window? = get()?.windowRef?.get() + private fun ensureWindowObserver(view: View) { val window = view.activityWindow() ?: return installObserverOnWindow(window) } private fun installObserverOnWindow(window: Window) { - pruneObservers() - if (isObserving(window)) { + observedWindows.removeAll { it.liveWindow() == null } + if (observedWindows.any { it.liveWindow() === window }) { return } val original = window.callback ?: return @@ -225,9 +342,7 @@ class TouchHoverCoordinator { continue } if (observer.windowRef.get() === window) { - if (window.callback === observer) { - window.callback = observer.original - } + restoreCallback(observer) iterator.remove() } } @@ -235,31 +350,22 @@ class TouchHoverCoordinator { private fun removeAllWindowObservers() { for (reference in observedWindows) { - val observer = reference.get() ?: continue - val window = observer.windowRef.get() ?: continue - if (window.callback === observer) { - window.callback = observer.original - } + reference.get()?.let { restoreCallback(it) } } observedWindows.clear() clearAll() } - private fun isObserving(window: Window) = observedWindows.any { it.get()?.windowRef?.get() === window } - - private fun isWindowObserved(view: View): Boolean { - val decor = view.rootView - return observedWindows.any { - it - .get() - ?.windowRef - ?.get() - ?.decorView === decor + private fun restoreCallback(observer: WindowObserver) { + val window = observer.windowRef.get() ?: return + if (window.callback === observer) { + window.callback = observer.original } } - private fun pruneObservers() { - observedWindows.removeAll { it.get()?.windowRef?.get() == null } + fun isWindowObserved(view: View): Boolean { + val decor = view.rootView + return observedWindows.any { it.liveWindow()?.decorView === decor } } private fun View.activityWindow(): Window? {