diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java
index efc03e8e158bda..2f13b1ee6ab8fd 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java
@@ -11,6 +11,8 @@
import android.text.Layout;
import android.text.Spannable;
+import androidx.annotation.Nullable;
+import com.facebook.react.bridge.ReadableMap;
/**
* Class that contains the data needed for a text update. Used by both and
@@ -31,6 +33,8 @@ public class ReactTextUpdate {
private final int mSelectionEnd;
private final int mJustificationMode;
+ public @Nullable ReadableMap mAttributedString = null;
+
/**
* @deprecated Use a non-deprecated constructor for ReactTextUpdate instead. This one remains
* because it's being used by a unit test that isn't currently open source.
@@ -135,6 +139,23 @@ public ReactTextUpdate(
mJustificationMode = justificationMode;
}
+ public static ReactTextUpdate buildReactTextUpdateFromState(
+ Spannable text,
+ int jsEventCounter,
+ boolean containsImages,
+ int textAlign,
+ int textBreakStrategy,
+ int justificationMode,
+ ReadableMap attributedString) {
+
+ ReactTextUpdate textUpdate =
+ new ReactTextUpdate(
+ text, jsEventCounter, containsImages, textAlign, textBreakStrategy, justificationMode);
+
+ textUpdate.mAttributedString = attributedString;
+ return textUpdate;
+ }
+
public Spannable getText() {
return mText;
}
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java
index 663c1443b28fc6..a0b54e20f6d8fb 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java
@@ -35,9 +35,8 @@
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.ViewCompat;
import com.facebook.infer.annotation.Assertions;
+import com.facebook.react.bridge.JavaOnlyMap;
import com.facebook.react.bridge.ReactContext;
-import com.facebook.react.bridge.WritableMap;
-import com.facebook.react.bridge.WritableNativeMap;
import com.facebook.react.uimanager.StateWrapper;
import com.facebook.react.uimanager.UIManagerModule;
import com.facebook.react.views.text.ReactSpan;
@@ -72,8 +71,13 @@ public class ReactEditText extends EditText {
private boolean mShouldAllowFocus;
private int mDefaultGravityHorizontal;
private int mDefaultGravityVertical;
+
+ /** A count of events sent to JS or C++. */
protected int mNativeEventCount;
+
+ /** The most recent event number acked by JavaScript. Should only be updated from JS, not C++. */
protected int mMostRecentEventCount;
+
private @Nullable ArrayList mListeners;
private @Nullable TextWatcherDelegator mTextWatcherDelegator;
private int mStagedInputType;
@@ -95,7 +99,11 @@ public class ReactEditText extends EditText {
private ReactViewBackgroundManager mReactBackgroundManager;
+ protected @Nullable JavaOnlyMap mAttributedString = null;
protected @Nullable StateWrapper mStateWrapper = null;
+ protected boolean mDisableTextDiffing = false;
+
+ protected boolean mIsSettingTextFromState = false;
private static final KeyListener sKeyListener = QwertyKeyListener.getInstanceForFullKeyboard();
@@ -279,17 +287,7 @@ public void setContentSizeWatcher(ContentSizeWatcher contentSizeWatcher) {
}
public void setMostRecentEventCount(int mostRecentEventCount) {
- if (mMostRecentEventCount == mostRecentEventCount) {
- return;
- }
-
mMostRecentEventCount = mostRecentEventCount;
-
- if (mStateWrapper != null) {
- WritableMap map = new WritableNativeMap();
- map.putInt("mostRecentEventCount", mMostRecentEventCount);
- mStateWrapper.updateState(map);
- }
}
public void setScrollWatcher(ScrollWatcher scrollWatcher) {
@@ -453,6 +451,18 @@ public int incrementAndGetEventCounter() {
return ++mNativeEventCount;
}
+ public void maybeSetTextFromJS(ReactTextUpdate reactTextUpdate) {
+ mIsSettingTextFromJS = true;
+ maybeSetText(reactTextUpdate);
+ mIsSettingTextFromJS = false;
+ }
+
+ public void maybeSetTextFromState(ReactTextUpdate reactTextUpdate) {
+ mIsSettingTextFromState = true;
+ maybeSetText(reactTextUpdate);
+ mIsSettingTextFromState = false;
+ }
+
// VisibleForTesting from {@link TextInputEventsTestCase}.
public void maybeSetText(ReactTextUpdate reactTextUpdate) {
if (isSecureText() && TextUtils.equals(getText(), reactTextUpdate.getText())) {
@@ -465,6 +475,10 @@ public void maybeSetText(ReactTextUpdate reactTextUpdate) {
return;
}
+ if (reactTextUpdate.mAttributedString != null) {
+ mAttributedString = JavaOnlyMap.deepClone(reactTextUpdate.mAttributedString);
+ }
+
// The current text gets replaced with the text received from JS. However, the spans on the
// current text need to be adapted to the new text. Since TextView#setText() will remove or
// reset some of these spans even if they are set directly, SpannableStringBuilder#replace() is
@@ -473,17 +487,24 @@ public void maybeSetText(ReactTextUpdate reactTextUpdate) {
new SpannableStringBuilder(reactTextUpdate.getText());
manageSpans(spannableStringBuilder);
mContainsImages = reactTextUpdate.containsImages();
- mIsSettingTextFromJS = true;
+
+ // When we update text, we trigger onChangeText code that will
+ // try to update state if the wrapper is available. Temporarily disable
+ // to prevent an (asynchronous) infinite loop.
+ mDisableTextDiffing = true;
// On some devices, when the text is cleared, buggy keyboards will not clear the composing
// text so, we have to set text to null, which will clear the currently composing text.
if (reactTextUpdate.getText().length() == 0) {
setText(null);
} else {
+ // When we update text, we trigger onChangeText code that will
+ // try to update state if the wrapper is available. Temporarily disable
+ // to prevent an infinite loop.
getText().replace(0, length(), spannableStringBuilder);
}
+ mDisableTextDiffing = false;
- mIsSettingTextFromJS = false;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (getBreakStrategy() != reactTextUpdate.getTextBreakStrategy()) {
setBreakStrategy(reactTextUpdate.getTextBreakStrategy());
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java
index 3b0b6c317ef2b1..db6666bfabb51b 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java
@@ -28,11 +28,17 @@
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.Dynamic;
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
+import com.facebook.react.bridge.JavaOnlyArray;
+import com.facebook.react.bridge.JavaOnlyMap;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReadableNativeMap;
import com.facebook.react.bridge.ReadableType;
+import com.facebook.react.bridge.WritableArray;
+import com.facebook.react.bridge.WritableMap;
+import com.facebook.react.bridge.WritableNativeArray;
+import com.facebook.react.bridge.WritableNativeMap;
import com.facebook.react.common.MapBuilder;
import com.facebook.react.module.annotations.ReactModule;
import com.facebook.react.uimanager.BaseViewManager;
@@ -225,7 +231,8 @@ public void receiveCommand(
// TODO: construct a ReactTextUpdate and use that with maybeSetText
// instead of calling setText, etc directly - doing that will definitely cause bugs.
- reactEditText.maybeSetText(getReactTextUpdate(text, mostRecentEventCount, start, end));
+ reactEditText.maybeSetTextFromJS(
+ getReactTextUpdate(text, mostRecentEventCount, start, end));
}
break;
}
@@ -257,7 +264,7 @@ public void updateExtraData(ReactEditText view, Object extraData) {
Spannable spannable = update.getText();
TextInlineImageSpan.possiblyUpdateInlineImageSpans(spannable, view);
}
- view.maybeSetText(update);
+ view.maybeSetTextFromState(update);
if (update.getSelectionStart() != UNSET && update.getSelectionEnd() != UNSET)
view.setSelection(update.getSelectionStart(), update.getSelectionEnd());
}
@@ -842,6 +849,10 @@ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
+ if (mEditText.mDisableTextDiffing) {
+ return;
+ }
+
// Rearranging the text (i.e. changing between singleline and multiline attributes) can
// also trigger onTextChanged, call the event in JS only when the text actually changed
if (count == 0 && before == 0) {
@@ -856,6 +867,92 @@ public void onTextChanged(CharSequence s, int start, int before, int count) {
return;
}
+ // Fabric: update representation of AttributedString
+ JavaOnlyMap attributedString = mEditText.mAttributedString;
+ if (attributedString != null && attributedString.hasKey("fragments")) {
+ String changedText = s.subSequence(start, start + count).toString();
+
+ String completeStr = attributedString.getString("string");
+ String newCompleteStr =
+ completeStr.substring(0, start)
+ + changedText
+ + (completeStr.length() > start + before
+ ? completeStr.substring(start + before)
+ : "");
+ attributedString.putString("string", newCompleteStr);
+
+ // Loop through all fragments and change them in-place
+ JavaOnlyArray fragments = (JavaOnlyArray) attributedString.getArray("fragments");
+ int positionInAttributedString = 0;
+ boolean found = false;
+ for (int i = 0; i < fragments.size() && !found; i++) {
+ JavaOnlyMap fragment = (JavaOnlyMap) fragments.getMap(i);
+ String fragmentStr = fragment.getString("string");
+ int positionBefore = positionInAttributedString;
+ positionInAttributedString += fragmentStr.length();
+ if (positionInAttributedString < start) {
+ continue;
+ }
+
+ int relativePosition = start - positionBefore;
+ found = true;
+
+ // Does the change span multiple Fragments?
+ // If so, we put any new text entirely in the first
+ // Fragment that we edit. For example, if you select two words
+ // across Fragment boundaries, "one | two", and replace them with a
+ // character "x", the first Fragment will replace "one " with "x", and the
+ // second Fragment will replace "two" with an empty string.
+ int remaining = fragmentStr.length() - relativePosition;
+
+ String newString =
+ fragmentStr.substring(0, relativePosition)
+ + changedText
+ + (fragmentStr.substring(relativePosition + Math.min(before, remaining)));
+ fragment.putString("string", newString);
+
+ // If we're changing 10 characters (before=10) and remaining=3,
+ // we want to remove 3 characters from this fragment (`Math.min(before, remaining)`)
+ // and 7 from the next Fragment (`before = 10 - 3`)
+ if (remaining < before) {
+ changedText = "";
+ start += remaining;
+ before = before - remaining;
+ found = false;
+ }
+ }
+ }
+
+ // Fabric: communicate to C++ layer that text has changed
+ // We need to call `incrementAndGetEventCounter` here explicitly because this
+ // update may race with other updates.
+ // TODO: currently WritableNativeMaps/WritableNativeArrays cannot be reused so
+ // we must recreate these data structures every time. It would be nice to have a
+ // reusable data-structure to use for TextInput because constructing these and copying
+ // on every keystroke is very expensive.
+ if (mEditText.mStateWrapper != null && attributedString != null) {
+ WritableMap map = new WritableNativeMap();
+ WritableMap newAttributedString = new WritableNativeMap();
+
+ WritableArray fragments = new WritableNativeArray();
+
+ for (int i = 0; i < attributedString.getArray("fragments").size(); i++) {
+ ReadableMap readableFragment = attributedString.getArray("fragments").getMap(i);
+ WritableMap fragment = new WritableNativeMap();
+ fragment.putDouble("reactTag", readableFragment.getInt("reactTag"));
+ fragment.putString("string", readableFragment.getString("string"));
+ fragments.pushMap(fragment);
+ }
+
+ newAttributedString.putString("string", attributedString.getString("string"));
+ newAttributedString.putArray("fragments", fragments);
+
+ map.putInt("mostRecentEventCount", mEditText.incrementAndGetEventCounter());
+ map.putMap("textChanged", newAttributedString);
+
+ mEditText.mStateWrapper.updateState(map);
+ }
+
// The event that contains the event counter and updates it must be sent first.
// TODO: t7936714 merge these events
mEventDispatcher.dispatchEvent(
@@ -1116,12 +1213,13 @@ public Object updateState(
view.mStateWrapper = stateWrapper;
- return new ReactTextUpdate(
+ return ReactTextUpdate.buildReactTextUpdateFromState(
spanned,
state.getInt("mostRecentEventCount"),
false, // TODO add this into local Data
textViewProps.getTextAlign(),
textBreakStrategy,
- justificationMode);
+ justificationMode,
+ attributedString);
}
}