mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
TextInput: keep C++ state in-sync with updated AttributedStrings in Java
Summary: When the TextInput is updated on the Java side, make sure C++ State gets updated. We do this by making sure that the AttributedString data-structured in mirrored in Java and in C++. In practice, the AttributedString is copied into Java a few times during initialization, and then after then, 99% of the time Java is writing without receiving updates from C++. This means that we should optimize the Java-to-C++ update path most aggressively in the future. However, it turns out that for now, at least, we can't reuse NativeWritableMaps/NativeWritableArrays because they're consumed on the C++ side and can't be modified after that. This is a perf improvement for the future. This allows us the user to edit any fragments, and the changes will flow through C++ State. This also allows us to edit across multiple Fragments. Changelog: [Internal] Reviewed By: mdvacca Differential Revision: D18785960 fbshipit-source-id: 97b283ec411081eca4d2d7a4cce2b31b5e237c42
This commit is contained in:
committed by
Facebook Github Bot
parent
2a46980535
commit
0bae47434e
@@ -11,6 +11,8 @@ import static com.facebook.react.views.text.TextAttributeProps.UNSET;
|
||||
|
||||
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 <Text/> and <TextInput/>
|
||||
@@ -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 class 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;
|
||||
}
|
||||
|
||||
@@ -35,9 +35,8 @@ import androidx.annotation.Nullable;
|
||||
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<TextWatcher> 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 class ReactEditText extends EditText {
|
||||
}
|
||||
|
||||
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 class ReactEditText extends EditText {
|
||||
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 class ReactEditText extends EditText {
|
||||
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 class ReactEditText extends EditText {
|
||||
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());
|
||||
|
||||
+102
-4
@@ -28,11 +28,17 @@ import com.facebook.common.logging.FLog;
|
||||
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 class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
|
||||
|
||||
// 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 class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
|
||||
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 class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
|
||||
|
||||
@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 class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
|
||||
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 class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user