diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomLineHeightSpan.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomLineHeightSpan.java index 9959bea4b82..0c8208468ba 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomLineHeightSpan.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomLineHeightSpan.java @@ -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); } 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 31a1139b697..7400b1df909 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,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 and @@ -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() { diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java index b9ec0233d8a..9c524d9f078 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java @@ -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; 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 79f35211152..570dd1f2fe0 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 @@ -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 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); } 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 774ceb25e46..9b18a940d21 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 @@ -926,7 +926,6 @@ public class ReactTextInputManager extends BaseViewManager 1; + int textBreakStrategy = TextAttributeProps.getTextBreakStrategy(paragraphAttributes.getString("textBreakStrategy")); @@ -1206,6 +1208,6 @@ public class ReactTextInputManager extends BaseViewManager