/* * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package com.facebook.react.uimanager import android.view.MotionEvent import android.view.ViewGroup import com.facebook.common.logging.FLog import com.facebook.infer.annotation.Assertions import com.facebook.react.bridge.ReactContext import com.facebook.react.common.ReactConstants import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.uimanager.common.UIManagerType import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.uimanager.events.TouchEvent import com.facebook.react.uimanager.events.TouchEventCoalescingKeyHelper import com.facebook.react.uimanager.events.TouchEventType /** * JSTouchDispatcher handles dispatching touches to JS from RootViews. If you implement RootView you * need to call handleTouchEvent from onTouchEvent and onInterceptTouchEvent. It will correctly find * the right view to handle the touch and also dispatch the appropriate event to JS */ public class JSTouchDispatcher(private val viewGroup: ViewGroup) { private var targetTag = -1 private val targetCoordinates = FloatArray(2) private var childIsHandlingNativeGesture = false private var gestureStartTime = TouchEvent.UNSET private val touchEventCoalescingKeyHelper: TouchEventCoalescingKeyHelper = TouchEventCoalescingKeyHelper() @OptIn(UnstableReactNativeAPI::class) public fun onChildStartedNativeGesture( androidEvent: MotionEvent, eventDispatcher: EventDispatcher, ) { onChildStartedNativeGesture(androidEvent, eventDispatcher, null) } @UnstableReactNativeAPI public fun onChildStartedNativeGesture( androidEvent: MotionEvent, eventDispatcher: EventDispatcher, reactContext: ReactContext?, ) { if (childIsHandlingNativeGesture) { // This means we previously had another child start handling this native gesture and now a // different native parent of that child has decided to intercept the touch stream and handle // the gesture itself. Example where this can happen: HorizontalScrollView in a ScrollView. return } dispatchCancelEvent(androidEvent, eventDispatcher) childIsHandlingNativeGesture = true if (targetTag != -1 && ReactNativeFeatureFlags.sweepActiveTouchOnChildNativeGesturesAndroid()) { val surfaceId = UIManagerHelper.getSurfaceId(viewGroup) sweepActiveTouchForTag(surfaceId, targetTag, reactContext) } targetTag = -1 } @Suppress("UNUSED_PARAMETER") public fun onChildEndedNativeGesture( androidEvent: MotionEvent, eventDispatcher: EventDispatcher, ) { // There should be only one child gesture at any given time. We can safely turn off the flag. childIsHandlingNativeGesture = false } public fun handleTouchEvent(ev: MotionEvent, eventDispatcher: EventDispatcher) { handleTouchEvent(ev, eventDispatcher, null) } /** * Main catalyst view is responsible for collecting and sending touch events to JS. This method * reacts for an incoming android native touch events ([MotionEvent]) and calls into * [com.facebook.react.uimanager.events.EventDispatcher] when appropriate. It uses * [com.facebook.react.uimanager.TouchTargetHelper.findTargetTagAndCoordinatesForTouch] helper * method for figuring out a react view ID in the case of ACTION_DOWN event (when the gesture * starts). */ public fun handleTouchEvent( ev: MotionEvent, eventDispatcher: EventDispatcher, reactContext: ReactContext?, ) { val action = ev.action and MotionEvent.ACTION_MASK if (action == MotionEvent.ACTION_DOWN) { if (targetTag != -1) { FLog.e(ReactConstants.TAG, "Got DOWN touch before receiving UP or CANCEL from last gesture") } // First event for this gesture. We expect tag to be set to -1, and we use helper method // [com.facebook.react.uimanager.NativeViewHierarchyManager.findTargetTagForTouch] to find // react view ID that will be responsible for handling // this gesture childIsHandlingNativeGesture = false gestureStartTime = ev.eventTime targetTag = findTargetTagAndSetCoordinates(ev) val surfaceId = UIManagerHelper.getSurfaceId(viewGroup) markActiveTouchForTag(surfaceId, targetTag, reactContext) eventDispatcher.dispatchEvent( TouchEvent.obtain( UIManagerHelper.getSurfaceId(viewGroup), targetTag, TouchEventType.START, ev, gestureStartTime, targetCoordinates[0], targetCoordinates[1], touchEventCoalescingKeyHelper, ) ) } else if (childIsHandlingNativeGesture) { // If the touch was intercepted by a child, we've already sent a cancel event to JS for this // gesture, so we shouldn't send any more touches related to it. return } else if (targetTag == -1) { // All the subsequent action types are expected to be called after ACTION_DOWN thus target // is supposed to be set for them. FLog.e( ReactConstants.TAG, "Unexpected state: received touch event but didn't get starting ACTION_DOWN for this " + "gesture before", ) } else if (action == MotionEvent.ACTION_UP) { // End of the gesture. We reset target tag to -1 and expect no further event associated with // this gesture. findTargetTagAndSetCoordinates(ev) val surfaceId = UIManagerHelper.getSurfaceId(viewGroup) eventDispatcher.dispatchEvent( TouchEvent.obtain( surfaceId, targetTag, TouchEventType.END, ev, gestureStartTime, targetCoordinates[0], targetCoordinates[1], touchEventCoalescingKeyHelper, ) ) sweepActiveTouchForTag(surfaceId, targetTag, reactContext) targetTag = -1 gestureStartTime = TouchEvent.UNSET } else if (action == MotionEvent.ACTION_MOVE) { // Update pointer position for current gesture findTargetTagAndSetCoordinates(ev) eventDispatcher.dispatchEvent( TouchEvent.obtain( UIManagerHelper.getSurfaceId(viewGroup), targetTag, TouchEventType.MOVE, ev, gestureStartTime, targetCoordinates[0], targetCoordinates[1], touchEventCoalescingKeyHelper, ) ) } else if (action == MotionEvent.ACTION_POINTER_DOWN) { // New pointer goes down, this can only happen after ACTION_DOWN is sent for the first pointer eventDispatcher.dispatchEvent( TouchEvent.obtain( UIManagerHelper.getSurfaceId(viewGroup), targetTag, TouchEventType.START, ev, gestureStartTime, targetCoordinates[0], targetCoordinates[1], touchEventCoalescingKeyHelper, ) ) } else if (action == MotionEvent.ACTION_POINTER_UP) { // Exactly one of the pointers goes up eventDispatcher.dispatchEvent( TouchEvent.obtain( UIManagerHelper.getSurfaceId(viewGroup), targetTag, TouchEventType.END, ev, gestureStartTime, targetCoordinates[0], targetCoordinates[1], touchEventCoalescingKeyHelper, ) ) } else if (action == MotionEvent.ACTION_CANCEL) { if (touchEventCoalescingKeyHelper.hasCoalescingKey(ev.downTime)) { dispatchCancelEvent(ev, eventDispatcher) } else { FLog.e( ReactConstants.TAG, "Received an ACTION_CANCEL touch event for which we have no corresponding ACTION_DOWN", ) } val surfaceId = UIManagerHelper.getSurfaceId(viewGroup) sweepActiveTouchForTag(surfaceId, targetTag, reactContext) targetTag = -1 gestureStartTime = TouchEvent.UNSET } else { FLog.w( ReactConstants.TAG, "Warning : touch event was ignored. Action=$action Target=$targetTag", ) } } private fun markActiveTouchForTag(surfaceId: Int, reactTag: Int, reactContext: ReactContext?) { if (reactContext == null) { return } val uiManager = UIManagerHelper.getUIManager(reactContext, UIManagerType.FABRIC) uiManager?.markActiveTouchForTag(surfaceId, reactTag) } private fun sweepActiveTouchForTag(surfaceId: Int, reactTag: Int, reactContext: ReactContext?) { if (reactContext == null) { return } val uiManager = UIManagerHelper.getUIManager(reactContext, UIManagerType.FABRIC) uiManager?.sweepActiveTouchForTag(surfaceId, reactTag) } private fun findTargetTagAndSetCoordinates(ev: MotionEvent): Int { // This method updates `targetCoordinates` with coordinates for the motion event. return TouchTargetHelper.findTargetTagAndCoordinatesForTouch( ev.x, ev.y, viewGroup, targetCoordinates, null, ) } private fun dispatchCancelEvent(androidEvent: MotionEvent, eventDispatcher: EventDispatcher) { // This means the gesture has already ended, via some other CANCEL or UP event. This is not // expected to happen very often as it would mean some child View has decided to intercept the // touch stream and start a native gesture only upon receiving the UP/CANCEL event. if (targetTag == -1) { FLog.w( ReactConstants.TAG, "Can't cancel already finished gesture. Is a child View trying to start a gesture from " + "an UP/CANCEL event?", ) return } Assertions.assertCondition( !childIsHandlingNativeGesture, "Expected to not have already sent a cancel for this gesture", ) Assertions.assertNotNull(eventDispatcher) .dispatchEvent( TouchEvent.obtain( UIManagerHelper.getSurfaceId(viewGroup), targetTag, TouchEventType.CANCEL, androidEvent, gestureStartTime, targetCoordinates[0], targetCoordinates[1], touchEventCoalescingKeyHelper, ) ) } }