Fix TextInput measurement caching

Summary:
This fixes TextInput measurement caching. Previously we were not setting the correct Spannable in the cache; we needed to additional Spans to it to indicate font size and other attributes.

This brings Fabric closer to how non-Fabric was measuring Spannables for TextInputs (see ReactTextInputShadowNode.java).

This should fix a few crashes and will be most noticeable with dynamically-sized multiline textinputs where the number of lines changes over time.

This also allows us to transmit less data from C++ to Java in the majority of cases.

Changelog: [Internal]

Differential Revision: D23670779

fbshipit-source-id: cf9b8c848b9e0c2619e01766b72b074248466825
This commit is contained in:
Joshua Gross
2020-09-12 21:53:22 -07:00
committed by Facebook GitHub Bot
parent 0b3f46b564
commit 6524e611d3
7 changed files with 197 additions and 45 deletions
@@ -17,7 +17,7 @@ import android.text.style.LineHeightSpan;
public class CustomLineHeightSpan implements LineHeightSpan, ReactSpan {
private final int mHeight;
CustomLineHeightSpan(float height) {
public CustomLineHeightSpan(float height) {
this.mHeight = (int) Math.ceil(height);
}
@@ -11,8 +11,6 @@ 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/>
@@ -33,7 +31,7 @@ public class ReactTextUpdate {
private final int mSelectionEnd;
private final int mJustificationMode;
public @Nullable ReadableMap mAttributedString = null;
public boolean mContainsMultipleFragments;
/**
* @deprecated Use a non-deprecated constructor for ReactTextUpdate instead. This one remains
@@ -145,14 +143,13 @@ public class ReactTextUpdate {
int textAlign,
int textBreakStrategy,
int justificationMode,
ReadableMap attributedString) {
boolean containsMultipleFragments) {
ReactTextUpdate textUpdate =
ReactTextUpdate reactTextUpdate =
new ReactTextUpdate(
text, jsEventCounter, false, textAlign, textBreakStrategy, justificationMode);
textUpdate.mAttributedString = attributedString;
return textUpdate;
reactTextUpdate.mContainsMultipleFragments = containsMultipleFragments;
return reactTextUpdate;
}
public Spannable getText() {
@@ -268,7 +268,7 @@ public class TextLayoutManager {
if (attributedString.hasKey("cacheId")) {
int cacheId = attributedString.getInt("cacheId");
if (sTagToSpannableCache.containsKey(cacheId)) {
text = sTagToSpannableCache.get(attributedString.getInt("cacheId"));
text = sTagToSpannableCache.get(cacheId);
} else {
return 0;
}
@@ -501,13 +501,13 @@ public class TextLayoutManager {
protected int start, end;
protected ReactSpan what;
SetSpanOperation(int start, int end, ReactSpan what) {
public SetSpanOperation(int start, int end, ReactSpan what) {
this.start = start;
this.end = end;
this.what = what;
}
public void execute(SpannableStringBuilder sb, int priority) {
public void execute(Spannable sb, int priority) {
// All spans will automatically extend to the right of the text, but not the left - except
// for spans that start at the beginning of the text.
int spanFlags = Spannable.SPAN_EXCLUSIVE_INCLUSIVE;
@@ -8,6 +8,7 @@
package com.facebook.react.views.textinput;
import static com.facebook.react.uimanager.UIManagerHelper.getReactContext;
import static com.facebook.react.views.text.TextAttributeProps.UNSET;
import android.content.Context;
import android.graphics.Rect;
@@ -17,7 +18,7 @@ import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputType;
import android.text.SpannableString;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
@@ -41,6 +42,10 @@ import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.uimanager.FabricViewStateManager;
import com.facebook.react.uimanager.UIManagerModule;
import com.facebook.react.views.text.CustomLetterSpacingSpan;
import com.facebook.react.views.text.CustomLineHeightSpan;
import com.facebook.react.views.text.CustomStyleSpan;
import com.facebook.react.views.text.ReactAbsoluteSizeSpan;
import com.facebook.react.views.text.ReactSpan;
import com.facebook.react.views.text.ReactTextUpdate;
import com.facebook.react.views.text.ReactTypefaceUtils;
@@ -49,6 +54,7 @@ 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;
import java.util.List;
/**
* A wrapper around the EditText that lets us better control what happens when an EditText gets
@@ -70,6 +76,7 @@ public class ReactEditText extends AppCompatEditText
// *TextChanged events should be triggered. This is less expensive than removing the text
// listeners and adding them back again after the text change is completed.
protected boolean mIsSettingTextFromJS;
protected boolean mIsSettingTextFromCacheUpdate = false;
private int mDefaultGravityHorizontal;
private int mDefaultGravityVertical;
@@ -325,7 +332,7 @@ public class ReactEditText extends AppCompatEditText
@Override
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
if (mSelectionWatcher != null && hasFocus()) {
if (!mIsSettingTextFromCacheUpdate && mSelectionWatcher != null && hasFocus()) {
mSelectionWatcher.onSelectionChanged(selStart, selEnd);
}
}
@@ -502,7 +509,7 @@ public class ReactEditText extends AppCompatEditText
SpannableStringBuilder spannableStringBuilder =
new SpannableStringBuilder(reactTextUpdate.getText());
manageSpans(spannableStringBuilder);
manageSpans(spannableStringBuilder, reactTextUpdate.mContainsMultipleFragments);
mContainsImages = reactTextUpdate.containsImages();
// When we update text, we trigger onChangeText code that will
@@ -528,10 +535,8 @@ public class ReactEditText extends AppCompatEditText
}
}
// Update cached spans (in Fabric only)
if (this.getFabricViewStateManager() != null) {
TextLayoutManager.setCachedSpannabledForTag(getId(), spannableStringBuilder);
}
// Update cached spans (in Fabric only).
updateCachedSpannable(false);
}
/**
@@ -540,30 +545,42 @@ public class ReactEditText extends AppCompatEditText
* will adapt to the new text, hence why {@link SpannableStringBuilder#replace} never removes
* them.
*/
private void manageSpans(SpannableStringBuilder spannableStringBuilder) {
private void manageSpans(
SpannableStringBuilder spannableStringBuilder, boolean skipAddSpansForMeasurements) {
Object[] spans = getText().getSpans(0, length(), Object.class);
for (int spanIdx = 0; spanIdx < spans.length; spanIdx++) {
Object span = spans[spanIdx];
int spanFlags = getText().getSpanFlags(span);
boolean isExclusiveExclusive =
(spanFlags & Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) == Spanned.SPAN_EXCLUSIVE_EXCLUSIVE;
// Remove all styling spans we might have previously set
if (spans[spanIdx] instanceof ReactSpan) {
getText().removeSpan(spans[spanIdx]);
if (span instanceof ReactSpan) {
getText().removeSpan(span);
}
if ((getText().getSpanFlags(spans[spanIdx]) & Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
!= Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) {
// We only add spans back for EXCLUSIVE_EXCLUSIVE spans
if (!isExclusiveExclusive) {
continue;
}
Object span = spans[spanIdx];
final int spanStart = getText().getSpanStart(spans[spanIdx]);
final int spanEnd = getText().getSpanEnd(spans[spanIdx]);
final int spanFlags = getText().getSpanFlags(spans[spanIdx]);
final int spanStart = getText().getSpanStart(span);
final int spanEnd = getText().getSpanEnd(span);
// Make sure the span is removed from existing text, otherwise the spans we set will be
// ignored or it will cover text that has changed.
getText().removeSpan(spans[spanIdx]);
getText().removeSpan(span);
if (sameTextForSpan(getText(), spannableStringBuilder, spanStart, spanEnd)) {
spannableStringBuilder.setSpan(span, spanStart, spanEnd, spanFlags);
}
}
// In Fabric only, apply necessary styles to entire span
// If the Spannable was constructed from multiple fragments, we don't apply any spans that could
// impact the whole Spannable, because that would override "local" styles per-fragment
if (!skipAddSpansForMeasurements) {
addSpansForMeasurement(getText());
}
}
private static boolean sameTextForSpan(
@@ -582,6 +599,75 @@ public class ReactEditText extends AppCompatEditText
return true;
}
// This is hacked in for Fabric. When we delete non-Fabric code, we might be able to simplify or
// clean this up a bit.
private void addSpansForMeasurement(Spannable spannable) {
if (!mFabricViewStateManager.hasStateWrapper()) {
return;
}
boolean originalDisableTextDiffing = mDisableTextDiffing;
mDisableTextDiffing = true;
int start = 0;
int end = spannable.length();
// Remove duplicate spans we might add here
Object[] spans = spannable.getSpans(0, length(), Object.class);
for (Object span : spans) {
int spanFlags = spannable.getSpanFlags(span);
boolean isInclusive =
(spanFlags & Spanned.SPAN_INCLUSIVE_INCLUSIVE) == Spanned.SPAN_INCLUSIVE_INCLUSIVE
|| (spanFlags & Spanned.SPAN_INCLUSIVE_EXCLUSIVE) == Spanned.SPAN_INCLUSIVE_EXCLUSIVE;
if (isInclusive
&& span instanceof ReactSpan
&& spannable.getSpanStart(span) == start
&& spannable.getSpanEnd(span) == end) {
spannable.removeSpan(span);
}
}
List<TextLayoutManager.SetSpanOperation> ops = new ArrayList<>();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (!Float.isNaN(mTextAttributes.getLetterSpacing())) {
ops.add(
new TextLayoutManager.SetSpanOperation(
start, end, new CustomLetterSpacingSpan(mTextAttributes.getLetterSpacing())));
}
}
ops.add(
new TextLayoutManager.SetSpanOperation(
start, end, new ReactAbsoluteSizeSpan((int) mTextAttributes.getEffectiveFontSize())));
if (mFontStyle != UNSET || mFontWeight != UNSET || mFontFamily != null) {
ops.add(
new TextLayoutManager.SetSpanOperation(
start,
end,
new CustomStyleSpan(
mFontStyle,
mFontWeight,
null, // TODO: do we need to support FontFeatureSettings / fontVariant?
mFontFamily,
getReactContext(ReactEditText.this).getAssets())));
}
if (!Float.isNaN(mTextAttributes.getEffectiveLineHeight())) {
ops.add(
new TextLayoutManager.SetSpanOperation(
start, end, new CustomLineHeightSpan(mTextAttributes.getEffectiveLineHeight())));
}
int priority = 0;
for (TextLayoutManager.SetSpanOperation op : ops) {
// Actual order of calling {@code execute} does NOT matter,
// but the {@code priority} DOES matter.
op.execute(spannable, priority);
priority++;
}
mDisableTextDiffing = originalDisableTextDiffing;
}
protected boolean showSoftKeyboard() {
return mInputMethodManager.showSoftInput(this, 0);
}
@@ -842,6 +928,59 @@ public class ReactEditText extends AppCompatEditText
return mFabricViewStateManager;
}
/**
* Update the cached Spannable used in TextLayoutManager to measure the text in Fabric. This is
* mostly copied from ReactTextInputShadowNode.java (the non-Fabric version) and
* TextLayoutManager.java with some very minor modifications. There's some duplication between
* here and TextLayoutManager, so there might be an opportunity for refactor.
*/
private void updateCachedSpannable(boolean resetStyles) {
// Noops in non-Fabric
if (getFabricViewStateManager() == null) {
return;
}
// If this view doesn't have an ID yet, we don't have a cache key, so bail here
if (getId() == -1) {
return;
}
if (resetStyles) {
mIsSettingTextFromCacheUpdate = true;
addSpansForMeasurement(getText());
mIsSettingTextFromCacheUpdate = false;
}
Editable currentText = getText();
boolean haveText = currentText != null && currentText.length() > 0;
SpannableStringBuilder sb = new SpannableStringBuilder();
// A note of caution: appending currentText to sb appends all the spans of currentText - not
// copies of the Spans, but the actual span objects. Any modifications to sb after that point
// can modify the spans of sb/currentText, impact the text or spans visible on screen, and
// also call the TextChangeWatcher methods.
if (haveText) {
sb.append(currentText);
}
// If we don't have text, make sure we have *something* to measure.
// Hint has the same dimensions - the only thing that's different is background or foreground
// color
if (!haveText) {
if (getHint() != null && getHint().length() > 0) {
sb.append(getHint());
} else {
// Measure something so we have correct height, even if there's no string.
sb.append("I");
}
// Make sure that all text styles are applied when we're measurable the hint or "blank" text
addSpansForMeasurement(sb);
}
TextLayoutManager.setCachedSpannabledForTag(getId(), sb);
}
/**
* This class will redirect *TextChanged calls to the listeners only in the case where the text is
* changed by the user, and not explicitly set by JS.
@@ -849,7 +988,7 @@ public class ReactEditText extends AppCompatEditText
private class TextWatcherDelegator implements TextWatcher {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
if (!mIsSettingTextFromJS && mListeners != null) {
if (!mIsSettingTextFromCacheUpdate && !mIsSettingTextFromJS && mListeners != null) {
for (TextWatcher listener : mListeners) {
listener.beforeTextChanged(s, start, count, after);
}
@@ -858,14 +997,15 @@ public class ReactEditText extends AppCompatEditText
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
if (!mIsSettingTextFromJS && mListeners != null) {
for (TextWatcher listener : mListeners) {
listener.onTextChanged(s, start, before, count);
if (!mIsSettingTextFromCacheUpdate) {
if (!mIsSettingTextFromJS && mListeners != null) {
for (TextWatcher listener : mListeners) {
listener.onTextChanged(s, start, before, count);
}
}
}
if (getFabricViewStateManager() != null) {
TextLayoutManager.setCachedSpannabledForTag(getId(), new SpannableString(getText()));
updateCachedSpannable(
!mIsSettingTextFromJS && !mIsSettingTextFromState && start == 0 && before == 0);
}
onContentSizeChange();
@@ -873,7 +1013,7 @@ public class ReactEditText extends AppCompatEditText
@Override
public void afterTextChanged(Editable s) {
if (!mIsSettingTextFromJS && mListeners != null) {
if (!mIsSettingTextFromCacheUpdate && !mIsSettingTextFromJS && mListeners != null) {
for (TextWatcher listener : mListeners) {
listener.afterTextChanged(s);
}
@@ -926,7 +926,6 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
return;
}
// Fabric: update representation of AttributedString
if (mEditText.getFabricViewStateManager().hasStateWrapper()) {
// Fabric: communicate to C++ layer that text has changed
// We need to call `incrementAndGetEventCounter` here explicitly because this
@@ -1197,6 +1196,9 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
TextLayoutManager.getOrCreateSpannableForText(
view.getContext(), attributedString, mReactTextViewManagerCallback);
boolean containsMultipleFragments =
attributedString.getArray("fragments").toArrayList().size() > 1;
int textBreakStrategy =
TextAttributeProps.getTextBreakStrategy(paragraphAttributes.getString("textBreakStrategy"));
@@ -1206,6 +1208,6 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
TextAttributeProps.getTextAlignment(props, TextLayoutManager.isRTL(attributedString)),
textBreakStrategy,
TextAttributeProps.getJustificationMode(props),
attributedString);
containsMultipleFragments);
}
}