diff --git a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index a77e5b42f71..f5d06e24595 100644 --- a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -385,6 +385,15 @@ export type NativeProps = $ReadOnly<{| |}>, >, + onTextInput?: ?BubblingEventHandler< + $ReadOnly<{| + target: Int32, + text: string, + previousText: string, + range: $ReadOnly<{|start: Double, end: Double|}>, + |}>, + >, + /** * Callback that is called when text input ends. */ @@ -653,6 +662,12 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { captured: 'onSubmitEditingCapture', }, }, + topTextInput: { + phasedRegistrationNames: { + bubbled: 'onTextInput', + captured: 'onTextInputCapture', + }, + }, }, directEventTypes: { topScroll: { diff --git a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js index 0aa8965bda3..2875ebb776d 100644 --- a/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js +++ b/packages/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js @@ -76,6 +76,9 @@ const RCTTextInputViewConfig = { }, }, directEventTypes: { + topTextInput: { + registrationName: 'onTextInput', + }, topScroll: { registrationName: 'onScroll', }, @@ -150,6 +153,7 @@ const RCTTextInputViewConfig = { onSelectionChange: true, onContentSizeChange: true, onScroll: true, + onTextInput: true, }), }, }; diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts index 0aa50fe80fc..7234cbb3c32 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts @@ -485,6 +485,15 @@ export interface TextInputSubmitEditingEventData { text: string; } +/** + * @see TextInputProps.onTextInput + */ +export interface TextInputTextInputEventData { + text: string; + previousText: string; + range: {start: number; end: number}; +} + /** * @see https://reactnative.dev/docs/textinput#props */ @@ -778,6 +787,16 @@ export interface TextInputProps | ((e: NativeSyntheticEvent) => void) | undefined; + /** + * Callback that is called on new text input with the argument + * `{ nativeEvent: { text, previousText, range: { start, end } } }`. + * + * This prop requires multiline={true} to be set. + */ + onTextInput?: + | ((e: NativeSyntheticEvent) => void) + | undefined; + /** * Invoked on content scroll with * `{ nativeEvent: { contentOffset: { x, y } } }`. diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h index 9a02b822496..209947de9b4 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h +++ b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h @@ -36,6 +36,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) RCTDirectEventBlock onSelectionChange; @property (nonatomic, copy, nullable) RCTDirectEventBlock onChange; @property (nonatomic, copy, nullable) RCTDirectEventBlock onChangeSync; +@property (nonatomic, copy, nullable) RCTDirectEventBlock onTextInput; @property (nonatomic, copy, nullable) RCTDirectEventBlock onScroll; @property (nonatomic, assign) NSInteger mostRecentEventCount; diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm index 20b953520b6..b0d71dcd350 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm +++ b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm @@ -493,6 +493,8 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithFrame : (CGRect)frame) } } + NSString *previousText = [backedTextInputView.attributedText.string copy] ?: @""; + if (range.location + range.length > backedTextInputView.attributedText.string.length) { _predictedText = backedTextInputView.attributedText.string; } else if (text != nil) { @@ -500,6 +502,17 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithFrame : (CGRect)frame) withString:text]; } + if (_onTextInput) { + _onTextInput(@{ + // We copy the string here because if it's a mutable string it may get released before we stop using it on a + // different thread, causing a crash. + @"text" : [text copy], + @"previousText" : previousText, + @"range" : @{@"start" : @(range.location), @"end" : @(range.location + range.length)}, + @"eventCount" : @(_nativeEventCount), + }); + } + return text; // Accepting the change. } diff --git a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm index 20749a60288..6d3d18b11eb 100644 --- a/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm +++ b/packages/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm @@ -64,6 +64,7 @@ RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(onKeyPressSync, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onChangeSync, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onSelectionChange, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onTextInput, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onScroll, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(mostRecentEventCount, NSInteger) diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index e8d90477f46..6e230c20b77 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -2281,6 +2281,14 @@ export type NativeProps = $ReadOnly<{| contentSize: $ReadOnly<{| width: Double, height: Double |}>, |}>, >, + onTextInput?: ?BubblingEventHandler< + $ReadOnly<{| + target: Int32, + text: string, + previousText: string, + range: $ReadOnly<{| start: Double, end: Double |}>, + |}>, + >, onEndEditing?: ?BubblingEventHandler< $ReadOnly<{| target: Int32, text: string |}>, >, diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 40078fc9cc9..7cf74483ef6 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -7662,6 +7662,15 @@ public class com/facebook/react/views/textinput/ReactTextChangedEvent : com/face public fun getEventName ()Ljava/lang/String; } +public class com/facebook/react/views/textinput/ReactTextInputEvent : com/facebook/react/uimanager/events/Event { + public static final field EVENT_NAME Ljava/lang/String; + public fun (IILjava/lang/String;Ljava/lang/String;II)V + public fun (ILjava/lang/String;Ljava/lang/String;II)V + public fun canCoalesce ()Z + protected fun getEventData ()Lcom/facebook/react/bridge/WritableMap; + public fun getEventName ()Ljava/lang/String; +} + public final class com/facebook/react/views/textinput/ReactTextInputLocalData { public fun (Landroid/widget/EditText;)V public fun apply (Landroid/widget/EditText;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputEvent.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputEvent.java new file mode 100644 index 00000000000..2f4fb2fa783 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputEvent.java @@ -0,0 +1,70 @@ +/* + * 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 androidx.annotation.Nullable; +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 text changes. VisibleForTesting from {@link + * TextInputEventsTestCase}. + */ +public class ReactTextInputEvent extends Event { + + public static final String EVENT_NAME = "topTextInput"; + + private String mText; + private String mPreviousText; + private int mRangeStart; + private int mRangeEnd; + + @Deprecated + public ReactTextInputEvent( + int viewId, String text, String previousText, int rangeStart, int rangeEnd) { + this(ViewUtil.NO_SURFACE_ID, viewId, text, previousText, rangeStart, rangeEnd); + } + + public ReactTextInputEvent( + int surfaceId, int viewId, String text, String previousText, int rangeStart, int rangeEnd) { + super(surfaceId, viewId); + mText = text; + mPreviousText = previousText; + mRangeStart = rangeStart; + mRangeEnd = rangeEnd; + } + + @Override + public String getEventName() { + return EVENT_NAME; + } + + @Override + public boolean canCoalesce() { + // We don't want to miss any textinput event, as event data is incremental. + return false; + } + + @Nullable + @Override + protected WritableMap getEventData() { + WritableMap eventData = Arguments.createMap(); + WritableMap range = Arguments.createMap(); + range.putDouble("start", mRangeStart); + range.putDouble("end", mRangeEnd); + + eventData.putString("text", mText); + eventData.putString("previousText", mPreviousText); + eventData.putMap("range", range); + + eventData.putInt("target", getViewTag()); + return eventData; + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.java index 9973b6ec2a5..6cf22e00f78 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputKeyPressEvent.java @@ -14,7 +14,7 @@ import com.facebook.react.uimanager.common.ViewUtil; import com.facebook.react.uimanager.events.Event; /** Event emitted by EditText native view when key pressed */ -/* package */ class ReactTextInputKeyPressEvent extends Event { +class ReactTextInputKeyPressEvent extends Event { public static final String EVENT_NAME = "topKeyPress"; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java index 6d9aac39b6c..eb6fcab6e0f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java @@ -230,6 +230,11 @@ public class ReactTextInputManager extends BaseViewManager