From 08cf562b810d05ac012e2b92707060fcc6da69a8 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Thu, 29 Jun 2023 12:42:10 +0200 Subject: [PATCH 1/5] add cursor position to onSelectionChanged --- .../textinput/ReactTextInputManager.java | 59 ++++++++++++++++--- .../ReactTextInputSelectionEvent.java | 43 ++++++++++++-- 2 files changed, 90 insertions(+), 12 deletions(-) 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 b27ace40cc98cf..883cc1fb6f40d9 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 @@ -24,11 +24,13 @@ import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.TextWatcher; +import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.EditorInfo; +import android.view.ViewTreeObserver; import android.widget.TextView; import androidx.annotation.Nullable; import androidx.autofill.HintConstants; @@ -1231,19 +1233,60 @@ public ReactSelectionWatcher(ReactEditText editText) { @Override public void onSelectionChanged(int start, int end) { - // Android will call us back for both the SELECTION_START span and SELECTION_END span in text - // To prevent double calling back into js we cache the result of the previous call and only - // forward it on if we have new values - - // Apparently Android might call this with an end value that is less than the start value - // Lets normalize them. See https://github.com/facebook/react-native/issues/18579 + Layout layout = mReactEditText.getLayout(); + if (layout == null) { + mReactEditText.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + mReactEditText.getViewTreeObserver().removeOnGlobalLayoutListener(this); + onSelectionChanged(start, end); + } + }); + return; + } int realStart = Math.min(start, end); int realEnd = Math.max(start, end); + int cursorPositionStartX = 0; + int cursorPositionStartY = 0; + int cursorPositionEndX = 0; + int cursorPositionEndY = 0; + + if (realStart == realEnd && realStart != 0) { + int lineStart = layout.getLineForOffset(realStart); + int baselineStart = layout.getLineBaseline(lineStart); + int ascentStart = layout.getLineAscent(lineStart); + cursorPositionStartX = (int) Math.round(PixelUtil.toDIPFromPixel(layout.getPrimaryHorizontal(realStart))); + cursorPositionStartY = (int) Math.round(PixelUtil.toDIPFromPixel(baselineStart + ascentStart)); + } + int lineEnd = layout.getLineForOffset(realEnd); + int baselineEnd = layout.getLineBaseline(lineEnd); + int ascentEnd = layout.getLineAscent(lineEnd); + + float right = layout.getPrimaryHorizontal(realEnd); + float bottom = layout.getLineBaseline(lineEnd) + layout.getLineDescent(lineEnd); + + + Drawable cursorDrawable = null; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + cursorDrawable = mReactEditText.getTextCursorDrawable(); + } + int cursorWidth = cursorDrawable != null ? cursorDrawable.getIntrinsicWidth() : 0; + cursorPositionEndX = (int) Math.round(PixelUtil.toDIPFromPixel(right)); + cursorPositionEndY = (int) Math.round(PixelUtil.toDIPFromPixel(bottom)); if (mPreviousSelectionStart != realStart || mPreviousSelectionEnd != realEnd) { mEventDispatcher.dispatchEvent( - new ReactTextInputSelectionEvent( - mSurfaceId, mReactEditText.getId(), realStart, realEnd)); + new ReactTextInputSelectionEvent( + mSurfaceId, + mReactEditText.getId(), + realStart, + realEnd, + cursorPositionStartX, + cursorPositionStartY, + cursorPositionEndX, + cursorPositionEndY + ) + ); mPreviousSelectionStart = realStart; mPreviousSelectionEnd = realEnd; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputSelectionEvent.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputSelectionEvent.java index ede96cd369f65e..09af37b935c8b4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputSelectionEvent.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputSelectionEvent.java @@ -20,17 +20,39 @@ private int mSelectionStart; private int mSelectionEnd; + private int mCursorPositionStartX; + private int mCursorPositionStartY; + private int mCursorPositionEndX; + private int mCursorPositionEndY; @Deprecated - public ReactTextInputSelectionEvent(int viewId, int selectionStart, int selectionEnd) { - this(ViewUtil.NO_SURFACE_ID, viewId, selectionStart, selectionEnd); + public ReactTextInputSelectionEvent( + int viewId, + int selectionStart, + int selectionEnd, + int cursorPositionStartX, + int cursorPositionStartY, + int cursorPositionEndX, + int cursorPositionEndY) { + this(-1, viewId, selectionStart, selectionEnd, cursorPositionStartX, cursorPositionStartY, cursorPositionEndX, cursorPositionEndY); } public ReactTextInputSelectionEvent( - int surfaceId, int viewId, int selectionStart, int selectionEnd) { + int surfaceId, + int viewId, + int selectionStart, + int selectionEnd, + int cursorPositionStartX, + int cursorPositionStartY, + int cursorPositionEndX, + int cursorPositionEndY) { super(surfaceId, viewId); mSelectionStart = selectionStart; mSelectionEnd = selectionEnd; + mCursorPositionStartX = cursorPositionStartX; + mCursorPositionStartY = cursorPositionStartY; + mCursorPositionEndX = cursorPositionEndX; + mCursorPositionEndY = cursorPositionEndY; } @Override @@ -42,10 +64,23 @@ public String getEventName() { @Override protected WritableMap getEventData() { WritableMap eventData = Arguments.createMap(); - WritableMap selectionData = Arguments.createMap(); + + WritableMap startPosition = Arguments.createMap(); + startPosition.putInt("x", mCursorPositionStartX); + startPosition.putInt("y", mCursorPositionStartY); + + WritableMap endPosition = Arguments.createMap(); + endPosition.putInt("x", mCursorPositionEndX); + endPosition.putInt("y", mCursorPositionEndY); + + WritableMap selectionPosition = Arguments.createMap(); + selectionPosition.putMap("start", startPosition); + selectionPosition.putMap("end", endPosition); + selectionData.putInt("end", mSelectionEnd); selectionData.putInt("start", mSelectionStart); + selectionData.putMap("cursorPosition", selectionPosition); eventData.putMap("selection", selectionData); return eventData; From ffc066183ebfd98e7b2f0a6e19fc469121c401bc Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Thu, 29 Jun 2023 13:56:48 +0200 Subject: [PATCH 2/5] example --- .../AndroidTextInputNativeComponent.js | 15 +++++- .../Components/TextInput/TextInput.d.ts | 33 +++++++++++- .../Components/TextInput/TextInput.flow.js | 20 +++++++ .../Components/TextInput/TextInput.js | 52 ++++++++++++++++++- .../TextInput/TextInputSharedExamples.js | 43 +++++++++++++-- 5 files changed, 156 insertions(+), 7 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index 55b770d26a35ef..89ef19a51ab2c4 100644 --- a/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/packages/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -409,7 +409,20 @@ export type NativeProps = $ReadOnly<{| onSelectionChange?: ?DirectEventHandler< $ReadOnly<{| target: Int32, - selection: $ReadOnly<{|start: Double, end: Double|}>, + selection: $ReadOnly<{| + start: Double, + end: Double, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: Double, + y: Double, + |}>, + end: $ReadOnly<{| + x: Double, + y: Double, + |}>, + |}>, + |}>, |}>, >, diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts index 97fe9371065419..819335524b2019 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts @@ -416,6 +416,16 @@ export interface TextInputSelectionChangeEventData extends TargetedEvent { selection: { start: number; end: number; + cursorPosition: { + start: { + x: number; + y: number; + }; + end: { + x: number; + y: number; + }; + }; }; } @@ -817,7 +827,28 @@ export interface TextInputProps * The start and end of the text input's selection. Set start and end to * the same value to position the cursor. */ - selection?: {start: number; end?: number | undefined} | undefined; + selection?: + | { + start: number; + end?: number | undefined; + cursorPosition: + | { + start: + | { + x: number; + y: number; + } + | undefined; + end: + | { + x: number; + y: number; + } + | undefined; + } + | undefined; + } + | undefined; /** * The highlight (and cursor on ios) color of the text input diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index be702737024815..dbb73fc58108ad 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -69,6 +69,16 @@ export type FocusEvent = TargetEvent; type Selection = $ReadOnly<{| start: number, end: number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>; export type SelectionChangeEvent = SyntheticEvent< @@ -837,6 +847,16 @@ export type Props = $ReadOnly<{| selection?: ?$ReadOnly<{| start: number, end?: ?number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>, /** diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 657145ef2ad781..64c601741c9923 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -39,7 +39,14 @@ type TextInputInstance = React.ElementRef> & { +clear: () => void, +isFocused: () => boolean, +getNativeRef: () => ?React.ElementRef>, - +setSelection: (start: number, end: number) => void, + +setSelection: ( + start: number, + end: number, + cursorPosition: { + start: {x: number, y: number}, + end: {x: number, y: number}, + }, + ) => void, }; let AndroidTextInput; @@ -79,6 +86,16 @@ export type TextInputEvent = SyntheticEvent< range: $ReadOnly<{| start: number, end: number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>, target: number, text: string, @@ -107,6 +124,16 @@ export type FocusEvent = TargetEvent; type Selection = $ReadOnly<{| start: number, end: number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>; export type SelectionChangeEvent = SyntheticEvent< @@ -798,7 +825,7 @@ export type Props = $ReadOnly<{| /** * Callback that is called when the text input selection is changed. * This will be called with - * `{ nativeEvent: { selection: { start, end } } }`. + * `{ nativeEvent: { selection: { start, end, cursorPosition: {start: {x, y}, end: {x, y}}} } }`. */ onSelectionChange?: ?(e: SelectionChangeEvent) => mixed, @@ -875,10 +902,21 @@ export type Props = $ReadOnly<{| /** * The start and end of the text input's selection. Set start and end to * the same value to position the cursor. + * cursorPosition specify the location of the cursor */ selection?: ?$ReadOnly<{| start: number, end?: ?number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>, /** @@ -1092,6 +1130,16 @@ function InternalTextInput(props: Props): React.Node { : { start: propsSelection.start, end: propsSelection.end ?? propsSelection.start, + cursorPosition: { + start: { + x: propsSelection?.cursorPosition?.start?.x ?? 0, + y: propsSelection?.cursorPosition?.start?.y ?? 0, + }, + end: { + x: propsSelection?.cursorPosition?.end?.x ?? 0, + y: propsSelection?.cursorPosition?.end?.y ?? 0, + }, + }, }; const [mostRecentEventCount, setMostRecentEventCount] = useState(0); diff --git a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js index c05948b0639800..cbcc25ce8e5c0e 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js @@ -478,6 +478,16 @@ type SelectionExampleState = { selection: $ReadOnly<{| start: number, end: number, + cursorPosition: $ReadOnly<{| + start: $ReadOnly<{| + x: number, + y: number, + |}>, + end: $ReadOnly<{| + x: number, + y: number, + |}>, + |}>, |}>, value: string, ... @@ -494,7 +504,14 @@ class SelectionExample extends React.Component< constructor(props) { super(props); this.state = { - selection: {start: 0, end: 0}, + selection: { + start: 0, + end: 0, + cursorPosition: { + start: {x: 0, y: 0}, + end: {x: 0, y: 0}, + }, + }, value: props.value, }; } @@ -512,7 +529,16 @@ class SelectionExample extends React.Component< select(start: number, end: number) { this._textInput?.focus(); - this.setState({selection: {start, end}}); + this.setState({ + selection: { + start, + end, + cursorPosition: { + start: {x: 0, y: 0}, + end: {x: 0, y: 0}, + }, + }, + }); if (this.props.imperative) { this._textInput?.setSelection(start, end); } @@ -555,7 +581,18 @@ class SelectionExample extends React.Component< selection ={' '} - {`{start:${this.state.selection.start},end:${this.state.selection.end}}`} + {`{ + start:${this.state.selection.start}, end:${this.state.selection.end}, + cursorPosition: { + start: { + x: ${this.state.selection.cursorPosition.start.x}, + y: ${this.state.selection.cursorPosition.start.y} + }, + end: { + x: ${this.state.selection.cursorPosition.end.x}, + y: ${this.state.selection.cursorPosition.end.y} + }, + }`} Date: Thu, 29 Jun 2023 14:30:15 +0200 Subject: [PATCH 3/5] update example --- .../js/examples/TextInput/TextInputSharedExamples.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js index cbcc25ce8e5c0e..6d85fcfbc828d7 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputSharedExamples.js @@ -533,10 +533,7 @@ class SelectionExample extends React.Component< selection: { start, end, - cursorPosition: { - start: {x: 0, y: 0}, - end: {x: 0, y: 0}, - }, + cursorPosition: {...this.state.selection.cursorPosition}, }, }); if (this.props.imperative) { From 64e3297f6a7ffd1967ec985a60ddbfa25bd04ad3 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Tue, 4 Jul 2023 12:23:51 +0200 Subject: [PATCH 4/5] fix start point --- .../textinput/ReactTextInputManager.java | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) 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 883cc1fb6f40d9..ca45d6f44a7f6f 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 @@ -1233,6 +1233,12 @@ public ReactSelectionWatcher(ReactEditText editText) { @Override public void onSelectionChanged(int start, int end) { + // Android will call us back for both the SELECTION_START span and SELECTION_END span in text + // To prevent double calling back into js, we cache the result of the previous call and only + // forward it on if we have new values + + // Apparently Android might call this with an end value that is less than the start value + // Lets normalize them. See https://github.com/facebook/react-native/issues/18579 Layout layout = mReactEditText.getLayout(); if (layout == null) { mReactEditText.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @@ -1251,29 +1257,31 @@ public void onGlobalLayout() { int cursorPositionEndX = 0; int cursorPositionEndY = 0; - if (realStart == realEnd && realStart != 0) { - int lineStart = layout.getLineForOffset(realStart); - int baselineStart = layout.getLineBaseline(lineStart); - int ascentStart = layout.getLineAscent(lineStart); - cursorPositionStartX = (int) Math.round(PixelUtil.toDIPFromPixel(layout.getPrimaryHorizontal(realStart))); - cursorPositionStartY = (int) Math.round(PixelUtil.toDIPFromPixel(baselineStart + ascentStart)); - } + int lineStart = layout.getLineForOffset(realStart); + int baselineStart = layout.getLineBaseline(lineStart); + int ascentStart = layout.getLineAscent(lineStart); + cursorPositionStartX = (int) Math.round(PixelUtil.toDIPFromPixel(layout.getPrimaryHorizontal(realStart))); + cursorPositionStartY = (int) Math.round(PixelUtil.toDIPFromPixel(baselineStart + ascentStart)); int lineEnd = layout.getLineForOffset(realEnd); int baselineEnd = layout.getLineBaseline(lineEnd); int ascentEnd = layout.getLineAscent(lineEnd); + int descentEnd = layout.getLineDescent(lineEnd); float right = layout.getPrimaryHorizontal(realEnd); float bottom = layout.getLineBaseline(lineEnd) + layout.getLineDescent(lineEnd); - + int cursorWidth = 0; Drawable cursorDrawable = null; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { cursorDrawable = mReactEditText.getTextCursorDrawable(); + if (cursorDrawable != null) { + cursorWidth = cursorDrawable.getIntrinsicWidth(); + } } - int cursorWidth = cursorDrawable != null ? cursorDrawable.getIntrinsicWidth() : 0; + + cursorPositionEndX = (int) Math.round(PixelUtil.toDIPFromPixel(right + cursorWidth)); + cursorPositionEndY = (int) Math.round(PixelUtil.toDIPFromPixel(bottom)); - cursorPositionEndX = (int) Math.round(PixelUtil.toDIPFromPixel(right)); - cursorPositionEndY = (int) Math.round(PixelUtil.toDIPFromPixel(bottom)); if (mPreviousSelectionStart != realStart || mPreviousSelectionEnd != realEnd) { mEventDispatcher.dispatchEvent( new ReactTextInputSelectionEvent( From bf6d784ffc9c631a782681a370c965079ec5cd5b Mon Sep 17 00:00:00 2001 From: Taras Perun <48593211+perunt@users.noreply.github.com> Date: Sun, 6 Aug 2023 20:18:38 +0200 Subject: [PATCH 5/5] remove log --- .../facebook/react/views/textinput/ReactTextInputManager.java | 1 - 1 file changed, 1 deletion(-) 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 ca45d6f44a7f6f..c37132cf7c01b5 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 @@ -24,7 +24,6 @@ import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.TextWatcher; -import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; import android.view.View;