Add support for blur and focus on View (#51570)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/51570

As the title suggests: adds support strictly to `View` components on Android for `onFocus` and `onBlur` events. This is especially helpful for apps that respond to controller or remote inputs and aligns with existing support for the `focusable` prop.

In order to make this change cross-compatible with text inputs, `TextInputFocusEvent` has been deprecated in favor of the `BlurEvent`/`FocusEvent` types now available from core. Their type signatures are identical but `BlurEvent`/`FocusEvent` should be the type going forward for all views that intend to support focus/blur. Text inputs intentionally do not forward information about their state upon focus/blur and docs specifically call out `onEndEditing` as a means of reading state synchronously when blurring. Therefore, the changes to the native side to remove the event type specifically for text inputs is not breaking.

Changelog: [Android][Added] - Support for `onFocus` and `onBlur` function calls in `View` components

Reviewed By: mdvacca

Differential Revision: D75238291

fbshipit-source-id: b991d1f24fc094ba9d5d466201ecd058f59258e9
This commit is contained in:
Peter Abbondanzo
2025-05-28 21:29:51 -07:00
committed by Facebook GitHub Bot
parent dd9d7c0e74
commit af0a76cf5f
16 changed files with 171 additions and 105 deletions
@@ -617,24 +617,12 @@ export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
uiViewClassName: 'AndroidTextInput',
bubblingEventTypes: {
topBlur: {
phasedRegistrationNames: {
bubbled: 'onBlur',
captured: 'onBlurCapture',
},
},
topEndEditing: {
phasedRegistrationNames: {
bubbled: 'onEndEditing',
captured: 'onEndEditingCapture',
},
},
topFocus: {
phasedRegistrationNames: {
bubbled: 'onFocus',
captured: 'onFocusCapture',
},
},
topKeyPress: {
phasedRegistrationNames: {
bubbled: 'onKeyPress',
@@ -17,6 +17,8 @@ import {
import {ColorValue, StyleProp} from '../../StyleSheet/StyleSheet';
import {TextStyle} from '../../StyleSheet/StyleSheetTypes';
import {
BlurEvent,
FocusEvent,
NativeSyntheticEvent,
NativeTouchEvent,
TargetedEvent,
@@ -455,7 +457,7 @@ export interface TextInputAndroidProps {
}
/**
* @deprecated Use `TextInputFocusEvent` instead
* @deprecated Use `FocusEvent` instead
*/
export interface TextInputFocusEventData extends TargetedEvent {
text: string;
@@ -464,6 +466,7 @@ export interface TextInputFocusEventData extends TargetedEvent {
/**
* @see TextInputProps.onFocus
* @deprecated Use `FocusEvent` instead
*/
export type TextInputFocusEvent = NativeSyntheticEvent<TextInputFocusEventData>;
@@ -809,8 +812,11 @@ export interface TextInputProps
/**
* Callback that is called when the text input is blurred
*
* Note: If you are trying to find the last value of TextInput, you can use the `onEndEditing`
* event, which is fired upon completion of editing.
*/
onBlur?: ((e: TextInputFocusEvent) => void) | undefined;
onBlur?: ((e: BlurEvent) => void) | undefined;
/**
* Callback that is called when the text input's text changes.
@@ -859,7 +865,7 @@ export interface TextInputProps
/**
* Callback that is called when the text input is focused
*/
onFocus?: ((e: TextInputFocusEvent) => void) | undefined;
onFocus?: ((e: FocusEvent) => void) | undefined;
/**
* Callback that is called when the text input selection is changed.
@@ -10,6 +10,8 @@
import type {HostInstance} from '../../../src/private/types/HostInstance';
import type {
BlurEvent,
FocusEvent,
GestureResponderEvent,
NativeSyntheticEvent,
ScrollEvent,
@@ -58,23 +60,23 @@ type TextInputContentSizeChangeEventData = $ReadOnly<{
export type TextInputContentSizeChangeEvent =
NativeSyntheticEvent<TextInputContentSizeChangeEventData>;
/**
* @see TextInputProps.onBlur
* @deprecated Use `BlurEvent` instead.
*/
export type TextInputBlurEvent = BlurEvent;
/**
* @see TextInputProps.onFocus
* @deprecated Use `FocusEvent` instead.
*/
export type TextInputFocusEvent = FocusEvent;
type TargetEvent = $ReadOnly<{
target: number,
...
}>;
type TextInputFocusEventData = TargetEvent;
/**
* @see TextInputProps.onBlur
*/
export type TextInputBlurEvent = NativeSyntheticEvent<TextInputFocusEventData>;
/**
* @see TextInputProps.onFocus
*/
export type TextInputFocusEvent = NativeSyntheticEvent<TextInputFocusEventData>;
export type Selection = $ReadOnly<{
start: number,
end: number,
@@ -11,6 +11,8 @@
import type {HostInstance} from '../../../src/private/types/HostInstance';
import type {____TextStyle_Internal as TextStyleInternal} from '../../StyleSheet/StyleSheetTypes';
import type {
BlurEvent,
FocusEvent,
GestureResponderEvent,
ScrollEvent,
} from '../../Types/CoreEventTypes';
@@ -86,10 +88,12 @@ if (Platform.OS === 'android') {
export type {
AutoCapitalize,
BlurEvent,
EnterKeyHintType,
EnterKeyHintTypeAndroid,
EnterKeyHintTypeIOS,
EnterKeyHintTypeOptions,
FocusEvent,
InputModeOptions,
KeyboardType,
KeyboardTypeAndroid,
@@ -520,14 +524,14 @@ function InternalTextInput(props: TextInputProps): React.Node {
});
};
const _onFocus = (event: TextInputFocusEvent) => {
const _onFocus = (event: FocusEvent) => {
TextInputState.focusInput(inputRef.current);
if (props.onFocus) {
props.onFocus(event);
}
};
const _onBlur = (event: TextInputBlurEvent) => {
const _onBlur = (event: BlurEvent) => {
TextInputState.blurInput(inputRef.current);
if (props.onBlur) {
props.onBlur(event);
@@ -12,7 +12,12 @@ import {Insets} from '../../../types/public/Insets';
import {GestureResponderHandlers} from '../../../types/public/ReactNativeRenderer';
import {StyleProp} from '../../StyleSheet/StyleSheet';
import {ViewStyle} from '../../StyleSheet/StyleSheetTypes';
import {LayoutChangeEvent, PointerEvents} from '../../Types/CoreEventTypes';
import {
BlurEvent,
FocusEvent,
LayoutChangeEvent,
PointerEvents,
} from '../../Types/CoreEventTypes';
import {Touchable} from '../Touchable/Touchable';
import {AccessibilityProps} from './ViewAccessibility';
@@ -76,6 +81,20 @@ export interface ViewPropsIOS extends TVViewPropsIOS {
}
export interface ViewPropsAndroid {
/**
* Callback that is called when the view is blurred.
*
* Note: This will only be called if the view is focusable.
*/
onBlur?: ((e: BlurEvent) => void) | null | undefined;
/**
* Callback that is called when the view is focused.
*
* Note: This will only be called if the view is focusable.
*/
onFocus?: ((e: FocusEvent) => void) | null | undefined;
/**
* Whether this view should render itself (and all of its children) into a single hardware texture on the GPU.
*
@@ -111,6 +111,18 @@ const bubblingEventTypes = {
bubbled: 'onClick',
},
},
topBlur: {
phasedRegistrationNames: {
captured: 'onBlurCapture',
bubbled: 'onBlur',
},
},
topFocus: {
phasedRegistrationNames: {
captured: 'onFocusCapture',
bubbled: 'onFocus',
},
},
};
const directEventTypes = {
@@ -248,6 +248,10 @@ export interface TargetedEvent {
target: number;
}
export type BlurEvent = NativeSyntheticEvent<TargetedEvent>;
export type FocusEvent = NativeSyntheticEvent<TargetedEvent>;
export interface PointerEvents {
onPointerEnter?: ((event: PointerEvent) => void) | undefined;
onPointerEnterCapture?: ((event: PointerEvent) => void) | undefined;
@@ -2672,13 +2672,12 @@ type TextInputContentSizeChangeEventData = $ReadOnly<{
}>;
export type TextInputContentSizeChangeEvent =
NativeSyntheticEvent<TextInputContentSizeChangeEventData>;
export type TextInputBlurEvent = BlurEvent;
export type TextInputFocusEvent = FocusEvent;
type TargetEvent = $ReadOnly<{
target: number,
...
}>;
type TextInputFocusEventData = TargetEvent;
export type TextInputBlurEvent = NativeSyntheticEvent<TextInputFocusEventData>;
export type TextInputFocusEvent = NativeSyntheticEvent<TextInputFocusEventData>;
export type Selection = $ReadOnly<{
start: number,
end: number,
@@ -3010,10 +3009,12 @@ export type TextInputType = InternalTextInput & TextInputComponentStatics;
exports[`public API should not change unintentionally Libraries/Components/TextInput/TextInput.js 1`] = `
"export type {
AutoCapitalize,
BlurEvent,
EnterKeyHintType,
EnterKeyHintTypeAndroid,
EnterKeyHintTypeIOS,
EnterKeyHintTypeOptions,
FocusEvent,
InputModeOptions,
KeyboardType,
KeyboardTypeAndroid,
@@ -6777,9 +6777,12 @@ 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
@@ -0,0 +1,27 @@
/*
* 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.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
/** Represents a View losing focus */
internal class BlurEvent(surfaceId: Int, viewId: Int) : Event<BlurEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun canCoalesce(): Boolean = false
protected override fun getEventData(): WritableMap {
return Arguments.createMap().apply { putInt("target", viewTag) }
}
internal companion object {
internal const val EVENT_NAME: String = "topBlur"
}
}
@@ -0,0 +1,27 @@
/*
* 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.events
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
/** Represents a View gaining focus */
internal class FocusEvent(surfaceId: Int, viewId: Int) : Event<FocusEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
override fun canCoalesce(): Boolean = false
protected override fun getEventData(): WritableMap {
return Arguments.createMap().apply { putInt("target", viewTag) }
}
internal companion object {
internal const val EVENT_NAME: String = "topFocus"
}
}
@@ -1,34 +0,0 @@
/*
* 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.views.textinput
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.common.ViewUtil
import com.facebook.react.uimanager.events.Event
/** Event emitted by EditText native view when it loses focus. */
internal class ReactTextInputBlurEvent(surfaceId: Int, viewId: Int) :
Event<ReactTextInputBlurEvent>(surfaceId, viewId) {
@Deprecated(
"Use the constructor with surfaceId instead",
ReplaceWith("ReactTextInputBlurEvent(surfaceId, viewId)"))
constructor(viewId: Int) : this(ViewUtil.NO_SURFACE_ID, viewId)
override fun getEventName(): String = EVENT_NAME
override fun canCoalesce(): Boolean = false
override fun getEventData(): WritableMap {
return Arguments.createMap().apply { putInt("target", viewTag) }
}
companion object {
private const val EVENT_NAME = "topBlur"
}
}
@@ -1,34 +0,0 @@
/*
* 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.views.textinput
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.common.ViewUtil
import com.facebook.react.uimanager.events.Event
/** Event emitted by EditText native view when it receives focus. */
internal class ReactTextInputFocusEvent(surfaceId: Int, viewId: Int) :
Event<ReactTextInputFocusEvent>(surfaceId, viewId) {
@Deprecated(
"Use the constructor with surfaceId instead",
ReplaceWith("ReactTextInputFocusEvent(surfaceId, viewId)"))
constructor(viewId: Int) : this(ViewUtil.NO_SURFACE_ID, viewId)
override fun getEventName(): String = EVENT_NAME
override fun getEventData(): WritableMap {
return Arguments.createMap().apply { putInt("target", viewTag) }
}
override fun canCoalesce(): Boolean = false
companion object {
private const val EVENT_NAME = "topFocus"
}
}
@@ -52,7 +52,9 @@ import com.facebook.react.uimanager.ViewDefaults
import com.facebook.react.uimanager.ViewProps
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.uimanager.annotations.ReactPropGroup
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.style.BorderRadiusProp
import com.facebook.react.uimanager.style.BorderStyle.Companion.fromString
import com.facebook.react.uimanager.style.LogicalEdge
@@ -896,9 +898,9 @@ public open class ReactTextInputManager public constructor() :
val surfaceId = reactContext.surfaceId
val eventDispatcher = getEventDispatcher(reactContext, editText)
if (hasFocus) {
eventDispatcher?.dispatchEvent(ReactTextInputFocusEvent(surfaceId, editText.id))
eventDispatcher?.dispatchEvent(FocusEvent(surfaceId, editText.id))
} else {
eventDispatcher?.dispatchEvent(ReactTextInputBlurEvent(surfaceId, editText.id))
eventDispatcher?.dispatchEvent(BlurEvent(surfaceId, editText.id))
eventDispatcher?.dispatchEvent(
ReactTextInputEndEditingEvent(surfaceId, editText.id, editText.text.toString()))
}
@@ -9,6 +9,7 @@ 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
@@ -33,6 +34,8 @@ 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
@@ -342,6 +345,40 @@ 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)
@@ -30,6 +30,7 @@ import {
AppStateStatus,
Appearance,
BackHandler,
BlurEvent,
Button,
ColorValue,
DevSettings,
@@ -42,6 +43,7 @@ import {
EventSubscription,
FlatList,
FlatListProps,
FocusEvent,
GestureResponderEvent,
HostComponent,
I18nManager,
@@ -1193,11 +1195,11 @@ class TextInputTest extends React.Component<{}, {username: string}> {
console.log(`y: ${e.nativeEvent.contentOffset.y}`);
};
handleOnBlur = (e: TextInputFocusEvent) => {
handleOnBlur = (e: BlurEvent) => {
testNativeSyntheticEvent(e);
};
handleOnFocus = (e: TextInputFocusEvent) => {
handleOnFocus = (e: FocusEvent) => {
testNativeSyntheticEvent(e);
};