From e960a28af7f4541dcf67d3c7148b2d32a39e1b04 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Thu, 5 Jun 2025 13:48:50 -0700 Subject: [PATCH] Add focus and blur dispatching logic to BaseViewManager (#51724) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/51724 Moves focus change listener logic from `ReactViewManager` to `BaseViewManager` so all view managers that extend the class can get focus/blur event dispatching for free. This does so by applying event listeners where `addEventEmitters` is called, so any extending classes must try to call `super.addEventEmitters` or implement it themselves. In the case of TextInput, this logic is re-implemented because the component emits an additional event when the text input is blurred and I wanted to avoid duplicate calls to get the event emitter for the view instance. In addition, I've added logic and a test case to ensure that any preexisting focus change listeners set on the view instance are called. There can only ever be one focus change listener tied to a view instance, so this ensures that ones created during view instantiation are retained. However, this does not guarantee that events are emitted for downstream users who overwrite the focus change listener later in the view's lifecycle (i.e. in response to a prop change or an extending view manager that doesn't call `super.addEventEmitters`). There is no clean way to enforce that the `BaseViewManager` focus change listener is always set without changing the generics and introducing a significant breaking change. Changelog: [Android][Added] - Adds support for onFocus/onBlur event dispatching logic to all native views that implement `BaseViewManager` Reviewed By: NickGerleman Differential Revision: D75579321 fbshipit-source-id: 02e1e6d0e78e9d05e4ec5bb59789f3097b73b3f8 --- .../ReactAndroid/api/ReactAndroid.api | 4 +- .../react/uimanager/BaseViewManager.java | 44 +++++++++++++++++++ .../views/textinput/ReactTextInputManager.kt | 11 ++--- .../react/views/view/ReactViewManager.kt | 37 ---------------- .../react/uimanager/BaseViewManagerTest.kt | 20 ++++++++- 5 files changed, 67 insertions(+), 49 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 6e087d2273d..30fd9846728 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3388,6 +3388,7 @@ public final class com/facebook/react/uimanager/BackgroundStyleApplicator { public abstract class com/facebook/react/uimanager/BaseViewManager : com/facebook/react/uimanager/ViewManager, android/view/View$OnLayoutChangeListener { public fun ()V public fun (Lcom/facebook/react/bridge/ReactApplicationContext;)V + protected fun addEventEmitters (Lcom/facebook/react/uimanager/ThemedReactContext;Landroid/view/View;)V public fun getExportedCustomBubblingEventTypeConstants ()Ljava/util/Map; public fun getExportedCustomDirectEventTypeConstants ()Ljava/util/Map; protected fun onAfterUpdateTransaction (Landroid/view/View;)V @@ -6784,12 +6785,9 @@ public class com/facebook/react/views/view/ReactViewManager : com/facebook/react public static final field Companion Lcom/facebook/react/views/view/ReactViewManager$Companion; public static final field REACT_CLASS Ljava/lang/String; public fun ()V - public synthetic fun addEventEmitters (Lcom/facebook/react/uimanager/ThemedReactContext;Landroid/view/View;)V - protected fun addEventEmitters (Lcom/facebook/react/uimanager/ThemedReactContext;Lcom/facebook/react/views/view/ReactViewGroup;)V public synthetic fun createViewInstance (Lcom/facebook/react/uimanager/ThemedReactContext;)Landroid/view/View; public fun createViewInstance (Lcom/facebook/react/uimanager/ThemedReactContext;)Lcom/facebook/react/views/view/ReactViewGroup; public fun getCommandsMap ()Ljava/util/Map; - public fun getExportedCustomBubblingEventTypeConstants ()Ljava/util/Map; public fun getName ()Ljava/lang/String; public fun nextFocusDown (Lcom/facebook/react/views/view/ReactViewGroup;I)V public fun nextFocusForward (Lcom/facebook/react/views/view/ReactViewGroup;I)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 97501e341c9..5b9ce2188f1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -12,6 +12,7 @@ import android.graphics.Paint; import android.os.Build; import android.text.TextUtils; import android.view.View; +import android.view.View.OnFocusChangeListener; import android.view.ViewGroup; import android.view.ViewParent; import android.view.accessibility.AccessibilityEvent; @@ -35,6 +36,9 @@ import com.facebook.react.uimanager.ReactAccessibilityDelegate.Role; import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.uimanager.common.UIManagerType; import com.facebook.react.uimanager.common.ViewUtil; +import com.facebook.react.uimanager.events.BlurEvent; +import com.facebook.react.uimanager.events.EventDispatcher; +import com.facebook.react.uimanager.events.FocusEvent; import com.facebook.react.uimanager.events.PointerEventHelper; import com.facebook.react.uimanager.style.OutlineStyle; import com.facebook.react.uimanager.util.ReactFindViewUtil; @@ -165,6 +169,36 @@ public abstract class BaseViewManager { + if (originalFocusChangeListener != null) { + originalFocusChangeListener.onFocusChange(v, hasFocus); + } + int surfaceId = UIManagerHelper.getSurfaceId(v.getContext()); + if (surfaceId == View.NO_ID) { + return; + } + if (view.getContext() instanceof ThemedReactContext) { + ThemedReactContext themedReactContext = (ThemedReactContext) v.getContext(); + @Nullable + EventDispatcher eventDispatcher = + UIManagerHelper.getEventDispatcherForReactTag(themedReactContext, view.getId()); + if (eventDispatcher != null) { + if (hasFocus) { + eventDispatcher.dispatchEvent(new FocusEvent(surfaceId, view.getId())); + } else { + eventDispatcher.dispatchEvent(new BlurEvent(surfaceId, view.getId())); + } + } + } + }); + } + // Currently, layout listener is only attached when transform or transformOrigin is set. @Override public void onLayoutChange( @@ -778,6 +812,16 @@ public abstract class BaseViewManager val surfaceId = reactContext.surfaceId val eventDispatcher = getEventDispatcher(reactContext, editText) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt index c40e222946b..7f16d4e4c32 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt @@ -9,7 +9,6 @@ package com.facebook.react.views.view import android.graphics.Rect import android.view.View -import android.view.View.OnFocusChangeListener import com.facebook.common.logging.FLog import com.facebook.react.bridge.Dynamic import com.facebook.react.bridge.DynamicFromObject @@ -34,8 +33,6 @@ import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.uimanager.annotations.ReactPropGroup import com.facebook.react.uimanager.common.UIManagerType import com.facebook.react.uimanager.common.ViewUtil -import com.facebook.react.uimanager.events.BlurEvent -import com.facebook.react.uimanager.events.FocusEvent import com.facebook.react.uimanager.style.BackgroundImageLayer import com.facebook.react.uimanager.style.BorderRadiusProp import com.facebook.react.uimanager.style.BorderStyle @@ -345,40 +342,6 @@ public open class ReactViewManager : ReactClippingViewManager() public override fun createViewInstance(context: ThemedReactContext): ReactViewGroup = ReactViewGroup(context) - override fun getExportedCustomBubblingEventTypeConstants(): Map { - val baseEventTypeConstants = super.getExportedCustomBubblingEventTypeConstants() - val eventTypeConstants = baseEventTypeConstants ?: mutableMapOf() - eventTypeConstants.putAll( - mapOf( - FocusEvent.EVENT_NAME to - mapOf( - "phasedRegistrationNames" to - mapOf("bubbled" to "onFocus", "captured" to "onFocusCapture")), - BlurEvent.EVENT_NAME to - mapOf( - "phasedRegistrationNames" to - mapOf("bubbled" to "onBlur", "captured" to "onBlurCapture")), - )) - return eventTypeConstants - } - - override fun addEventEmitters(reactContext: ThemedReactContext, view: ReactViewGroup) { - view.onFocusChangeListener = OnFocusChangeListener { _: View?, hasFocus: Boolean -> - val surfaceId = UIManagerHelper.getSurfaceId(view.context) - if (surfaceId == View.NO_ID) { - return@OnFocusChangeListener - } - val eventDispatcher = - UIManagerHelper.getEventDispatcherForReactTag((view.context as ReactContext), view.id) - ?: return@OnFocusChangeListener - if (hasFocus) { - eventDispatcher.dispatchEvent(FocusEvent(surfaceId, view.id)) - } else { - eventDispatcher.dispatchEvent(BlurEvent(surfaceId, view.id)) - } - } - } - override fun getCommandsMap(): MutableMap = mutableMapOf(HOTSPOT_UPDATE_KEY to CMD_HOTSPOT_UPDATE, "setPressed" to CMD_SET_PRESSED) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt index 24ccffee48a..f99ded815f2 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt @@ -7,8 +7,10 @@ package com.facebook.react.uimanager +import android.view.View.OnFocusChangeListener import com.facebook.react.R import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.WritableArray @@ -23,6 +25,9 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.MockedStatic import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @@ -31,12 +36,15 @@ class BaseViewManagerTest { private lateinit var viewManager: BaseViewManager private lateinit var view: ReactViewGroup private lateinit var arguments: MockedStatic + private lateinit var themedReactContext: ThemedReactContext @Before fun setUp() { ReactNativeFeatureFlagsForTests.setUp() viewManager = ReactViewManager() - view = ReactViewGroup(RuntimeEnvironment.getApplication()) + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + themedReactContext = ThemedReactContext(context, context, null, -1) + view = ReactViewGroup(themedReactContext) arguments = Mockito.mockStatic(Arguments::class.java) arguments.`when` { Arguments.createMap() }.thenAnswer { JavaOnlyArray() } } @@ -75,4 +83,14 @@ class BaseViewManagerTest { viewManager.setRole(view, "list") Assertions.assertThat(view.getTag(R.id.role)).isEqualTo(ReactAccessibilityDelegate.Role.LIST) } + + @Test + fun testAddEventEmittersDoesNotOverrideExistingEventEmitters() { + val originalFocusListener = mock() + view.onFocusChangeListener = originalFocusListener + viewManager.addEventEmitters(themedReactContext, view) + Assertions.assertThat(view.onFocusChangeListener).isNotEqualTo(originalFocusListener) + view.onFocusChangeListener.onFocusChange(view, true) + verify(originalFocusListener, times(1)).onFocusChange(view, true) + } }