mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
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:
committed by
Facebook GitHub Bot
parent
0b3f46b564
commit
6524e611d3
@@ -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);
|
||||
}
|
||||
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user