Simplify TextInput measurements

Summary:
Simplify the TextInput measurement mechanism.

Now, data only flows from JS->C++->Java and from Java->JS. C++ passes along AttributedStrings from JS if JS updates, and otherwise Java maintains the only source of truth.

Previously we tried to keep all three in sync. This was complicated, slow, and even lead to some crashes.

This feels a bit hacky but I believe it's the simplest way to achieve this short-term. Ideally, we would use something like `AttributedStringBox` and pass that to State from Java,
but currently everything passed through the State system from Java must be serializable as `folly::dynamic`. So, instead, we just cache one Spannable per TextInput component and
use ReactTag as the cache identifier for lookup.

An interesting side-effect is that `measure` could race with TextInput updates, but the race condition favors measuring the latest text, not outdated values.

Followups:

- Can we do this without copying the EditText Spannable on every keystroke? Maybe this approach is too aggressive, but I don't want a background thread measuring a Spannable as it's being mutated.
- Do we need to support measuring Attachments?
- How can we clean up this API? It should work for now, but feels a little hacky.

Changelog: [Internal]

Reviewed By: mdvacca

Differential Revision: D23290230

fbshipit-source-id: 832d2f397d30dfb17b77958af970d9c52a37e88b
This commit is contained in:
Joshua Gross
2020-08-24 11:59:28 -07:00
committed by Facebook GitHub Bot
parent 00563575ba
commit d602c51996
8 changed files with 149 additions and 154 deletions
@@ -21,6 +21,7 @@ import android.text.TextPaint;
import android.util.LayoutDirection;
import android.util.LruCache;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.facebook.common.logging.FLog;
import com.facebook.react.bridge.ReadableArray;
@@ -33,6 +34,7 @@ import com.facebook.yoga.YogaMeasureMode;
import com.facebook.yoga.YogaMeasureOutput;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/** Class responsible of creating {@link Spanned} object for the JS representation of Text */
public class TextLayoutManager {
@@ -57,7 +59,10 @@ public class TextLayoutManager {
private static final String INCLUDE_FONT_PADDING_KEY = "includeFontPadding";
private static final String TEXT_BREAK_STRATEGY_KEY = "textBreakStrategy";
private static final String MAXIMUM_NUMBER_OF_LINES_KEY = "maximumNumberOfLines";
private static LruCache<String, Spannable> sSpannableCache = new LruCache<>(spannableCacheSize);
private static final LruCache<String, Spannable> sSpannableCache =
new LruCache<>(spannableCacheSize);
private static final ConcurrentHashMap<Integer, Spannable> sTagToSpannableCache =
new ConcurrentHashMap<>();
public static boolean isRTL(ReadableMap attributedString) {
ReadableArray fragments = attributedString.getArray("fragments");
@@ -70,6 +75,14 @@ public class TextLayoutManager {
return false;
}
public static void setCachedSpannabledForTag(int reactTag, @NonNull Spannable sp) {
sTagToSpannableCache.put(reactTag, sp);
}
public static void deleteCachedSpannableForTag(int reactTag) {
sTagToSpannableCache.remove(reactTag);
}
private static void buildSpannableFromFragment(
Context context,
ReadableArray fragments,
@@ -227,8 +240,17 @@ public class TextLayoutManager {
// TODO(5578671): Handle text direction (see View#getTextDirectionHeuristic)
TextPaint textPaint = sTextPaintInstance;
Spannable text =
getOrCreateSpannableForText(context, attributedString, reactTextViewManagerCallback);
Spannable text;
if (attributedString.hasKey("cacheId")) {
int cacheId = attributedString.getInt("cacheId");
if (sTagToSpannableCache.containsKey(cacheId)) {
text = sTagToSpannableCache.get(attributedString.getInt("cacheId"));
} else {
return 0;
}
} else {
text = getOrCreateSpannableForText(context, attributedString, reactTextViewManagerCallback);
}
int textBreakStrategy =
TextAttributeProps.getTextBreakStrategy(
@@ -17,6 +17,7 @@ import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputType;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
@@ -37,7 +38,6 @@ import androidx.appcompat.widget.AppCompatEditText;
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.uimanager.FabricViewStateManager;
import com.facebook.react.uimanager.UIManagerModule;
@@ -46,6 +46,7 @@ import com.facebook.react.views.text.ReactTextUpdate;
import com.facebook.react.views.text.ReactTypefaceUtils;
import com.facebook.react.views.text.TextAttributes;
import com.facebook.react.views.text.TextInlineImageSpan;
import com.facebook.react.views.text.TextLayoutManager;
import com.facebook.react.views.view.ReactViewBackgroundManager;
import java.util.ArrayList;
@@ -100,7 +101,6 @@ public class ReactEditText extends AppCompatEditText
private ReactViewBackgroundManager mReactBackgroundManager;
protected @Nullable JavaOnlyMap mAttributedString = null;
private final FabricViewStateManager mFabricViewStateManager = new FabricViewStateManager();
protected boolean mDisableTextDiffing = false;
@@ -152,6 +152,11 @@ public class ReactEditText extends AppCompatEditText
});
}
@Override
protected void finalize() {
TextLayoutManager.deleteCachedSpannableForTag(getId());
}
// After the text changes inside an EditText, TextView checks if a layout() has been requested.
// If it has, it will not scroll the text to the end of the new text inserted, but wait for the
// next layout() to be called. However, we do not perform a layout() after a requestLayout(), so
@@ -481,16 +486,13 @@ public class ReactEditText extends AppCompatEditText
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
// used instead (this is also used by the keyboard implementation underneath the covers).
SpannableStringBuilder spannableStringBuilder =
new SpannableStringBuilder(reactTextUpdate.getText());
manageSpans(spannableStringBuilder);
mContainsImages = reactTextUpdate.containsImages();
@@ -516,6 +518,11 @@ public class ReactEditText extends AppCompatEditText
setBreakStrategy(reactTextUpdate.getTextBreakStrategy());
}
}
// Update cached spans (in Fabric only)
if (this.getFabricViewStateManager() != null) {
TextLayoutManager.setCachedSpannabledForTag(getId(), spannableStringBuilder);
}
}
/**
@@ -848,6 +855,10 @@ public class ReactEditText extends AppCompatEditText
}
}
if (getFabricViewStateManager() != null) {
TextLayoutManager.setCachedSpannabledForTag(getId(), new SpannableString(getText()));
}
onContentSizeChange();
}
@@ -35,17 +35,13 @@ 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.ReactSoftException;
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;
@@ -250,8 +246,6 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
}
}
// TODO: if we're able to fill in all these values and call maybeSetText when appropriate
// I think this is all that's needed to fully support TextInput in Fabric
private ReactTextUpdate getReactTextUpdate(
String text, int mostRecentEventCount, int start, int end) {
SpannableStringBuilder sb = new SpannableStringBuilder();
@@ -920,69 +914,12 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
}
// Fabric: update representation of AttributedString
final 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.getFabricViewStateManager().hasStateWrapper() && attributedString != null) {
if (mEditText.getFabricViewStateManager().hasStateWrapper()) {
// Fabric: communicate to C++ layer that text has changed
// We need to call `incrementAndGetEventCounter` here explicitly because this
// update may race with other updates.
// We simply pass in the cache ID, which never changes, but UpdateState will still be called
// on the native side, triggering a measure.
mEditText
.getFabricViewStateManager()
.setState(
@@ -990,24 +927,8 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
@Override
public WritableMap getStateUpdate() {
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);
map.putInt("opaqueCacheId", mEditText.getId());
return map;
}
});
@@ -1247,11 +1168,11 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
view.getFabricViewStateManager().setStateWrapper(stateWrapper);
if (stateWrapper == null) {
throw new IllegalArgumentException("Unable to update a NULL state.");
}
ReadableNativeMap state = stateWrapper.getState();
if (!state.hasKey("attributedString")) {
return null;
}
ReadableMap attributedString = state.getMap("attributedString");
ReadableMap paragraphAttributes = state.getMap("paragraphAttributes");