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
This commit is contained in:
Peter Abbondanzo
2025-06-05 13:48:50 -07:00
committed by Facebook GitHub Bot
parent cd5d74518b
commit e960a28af7
5 changed files with 67 additions and 49 deletions
@@ -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 <init> ()V
public fun <init> (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 <init> ()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
@@ -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<T extends View, C extends LayoutShadowNode
return view;
}
@Override
protected void addEventEmitters(@NonNull ThemedReactContext reactContext, @NonNull T view) {
super.addEventEmitters(reactContext, view);
@Nullable OnFocusChangeListener originalFocusChangeListener = view.getOnFocusChangeListener();
view.setOnFocusChangeListener(
(v, hasFocus) -> {
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<T extends View, C extends LayoutShadowNode
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onClick", "captured", "onClickCapture")))
.put(
"topBlur",
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onBlur", "captured", "onBlurCapture")))
.put(
"topFocus",
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onFocus", "captured", "onFocusCapture")))
.build());
return eventTypeConstants;
}
@@ -123,14 +123,6 @@ public open class ReactTextInputManager public constructor() :
mapOf(
"phasedRegistrationNames" to
mapOf("bubbled" to "onEndEditing", "captured" to "onEndEditingCapture")),
"topFocus" to
mapOf(
"phasedRegistrationNames" to
mapOf("bubbled" to "onFocus", "captured" to "onFocusCapture")),
"topBlur" to
mapOf(
"phasedRegistrationNames" to
mapOf("bubbled" to "onBlur", "captured" to "onBlurCapture")),
"topKeyPress" to
mapOf(
"phasedRegistrationNames" to
@@ -894,6 +886,9 @@ public open class ReactTextInputManager public constructor() :
override fun addEventEmitters(reactContext: ThemedReactContext, editText: ReactEditText) {
editText.setEventDispatcher(getEventDispatcher(reactContext, editText))
editText.addTextChangedListener(ReactTextInputTextWatcher(reactContext, editText))
// Implements focus/blur dispatching on behalf of BaseViewManager since only one focus listener
// can be set on a view instance
editText.onFocusChangeListener = OnFocusChangeListener { _: View?, hasFocus: Boolean ->
val surfaceId = reactContext.surfaceId
val eventDispatcher = getEventDispatcher(reactContext, editText)
@@ -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<ReactViewGroup>()
public override fun createViewInstance(context: ThemedReactContext): ReactViewGroup =
ReactViewGroup(context)
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any> {
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<String, Int> =
mutableMapOf(HOTSPOT_UPDATE_KEY to CMD_HOTSPOT_UPDATE, "setPressed" to CMD_SET_PRESSED)
@@ -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<ReactViewGroup, *>
private lateinit var view: ReactViewGroup
private lateinit var arguments: MockedStatic<Arguments>
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`<WritableArray> { 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<OnFocusChangeListener>()
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)
}
}