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); } }