Merge pull request #36874 from facebook/kelset/071-backport-textinput-fixes

This commit is contained in:
Lorenzo Sciandra
2023-04-17 11:17:04 +01:00
committed by GitHub
6 changed files with 236 additions and 132 deletions
@@ -37,6 +37,10 @@ public class CustomLetterSpacingSpan extends MetricAffectingSpan implements Reac
apply(paint);
}
public float getSpacing() {
return mLetterSpacing;
}
private void apply(TextPaint paint) {
if (!Float.isNaN(mLetterSpacing)) {
paint.setLetterSpacing(mLetterSpacing);
@@ -71,6 +71,10 @@ public class CustomStyleSpan extends MetricAffectingSpan implements ReactSpan {
return mFontFamily;
}
public @Nullable String getFontFeatureSettings() {
return mFeatureSettings;
}
private static void apply(
Paint paint,
int style,
@@ -31,8 +31,6 @@ public class ReactTextUpdate {
private final int mSelectionEnd;
private final int mJustificationMode;
public boolean mContainsMultipleFragments;
/**
* @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.
@@ -142,13 +140,11 @@ public class ReactTextUpdate {
int jsEventCounter,
int textAlign,
int textBreakStrategy,
int justificationMode,
boolean containsMultipleFragments) {
int justificationMode) {
ReactTextUpdate reactTextUpdate =
new ReactTextUpdate(
text, jsEventCounter, false, textAlign, textBreakStrategy, justificationMode);
reactTextUpdate.mContainsMultipleFragments = containsMultipleFragments;
return reactTextUpdate;
}
@@ -11,6 +11,8 @@ import static com.facebook.react.uimanager.UIManagerHelper.getReactContext;
import static com.facebook.react.views.text.TextAttributeProps.UNSET;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
@@ -50,15 +52,19 @@ 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.ReactBackgroundColorSpan;
import com.facebook.react.views.text.ReactForegroundColorSpan;
import com.facebook.react.views.text.ReactSpan;
import com.facebook.react.views.text.ReactStrikethroughSpan;
import com.facebook.react.views.text.ReactTextUpdate;
import com.facebook.react.views.text.ReactTypefaceUtils;
import com.facebook.react.views.text.ReactUnderlineSpan;
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;
import java.util.List;
import java.util.Objects;
/**
* A wrapper around the EditText that lets us better control what happens when an EditText gets
@@ -82,7 +88,6 @@ 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;
@@ -362,7 +367,7 @@ public class ReactEditText extends AppCompatEditText
}
super.onSelectionChanged(selStart, selEnd);
if (!mIsSettingTextFromCacheUpdate && mSelectionWatcher != null && hasFocus()) {
if (mSelectionWatcher != null && hasFocus()) {
mSelectionWatcher.onSelectionChanged(selStart, selEnd);
}
}
@@ -512,6 +517,14 @@ public class ReactEditText extends AppCompatEditText
}
}
@Override
public void setFontFeatureSettings(String fontFeatureSettings) {
if (!Objects.equals(fontFeatureSettings, getFontFeatureSettings())) {
super.setFontFeatureSettings(fontFeatureSettings);
mTypefaceDirty = true;
}
}
public void maybeUpdateTypeface() {
if (!mTypefaceDirty) {
return;
@@ -523,6 +536,17 @@ public class ReactEditText extends AppCompatEditText
ReactTypefaceUtils.applyStyles(
getTypeface(), mFontStyle, mFontWeight, mFontFamily, getContext().getAssets());
setTypeface(newTypeface);
// Match behavior of CustomStyleSpan and enable SUBPIXEL_TEXT_FLAG when setting anything
// nonstandard
if (mFontStyle != UNSET
|| mFontWeight != UNSET
|| mFontFamily != null
|| getFontFeatureSettings() != null) {
setPaintFlags(getPaintFlags() | Paint.SUBPIXEL_TEXT_FLAG);
} else {
setPaintFlags(getPaintFlags() & (~Paint.SUBPIXEL_TEXT_FLAG));
}
}
// VisibleForTesting from {@link TextInputEventsTestCase}.
@@ -584,10 +608,8 @@ public class ReactEditText extends AppCompatEditText
SpannableStringBuilder spannableStringBuilder =
new SpannableStringBuilder(reactTextUpdate.getText());
manageSpans(spannableStringBuilder, reactTextUpdate.mContainsMultipleFragments);
// Mitigation for https://github.com/facebook/react-native/issues/35936 (S318090)
stripAbsoluteSizeSpans(spannableStringBuilder);
manageSpans(spannableStringBuilder);
stripStyleEquivalentSpans(spannableStringBuilder);
mContainsImages = reactTextUpdate.containsImages();
@@ -615,7 +637,7 @@ public class ReactEditText extends AppCompatEditText
}
// Update cached spans (in Fabric only).
updateCachedSpannable(false);
updateCachedSpannable();
}
/**
@@ -624,8 +646,7 @@ public class ReactEditText extends AppCompatEditText
* will adapt to the new text, hence why {@link SpannableStringBuilder#replace} never removes
* them.
*/
private void manageSpans(
SpannableStringBuilder spannableStringBuilder, boolean skipAddSpansForMeasurements) {
private void manageSpans(SpannableStringBuilder spannableStringBuilder) {
Object[] spans = getText().getSpans(0, length(), Object.class);
for (int spanIdx = 0; spanIdx < spans.length; spanIdx++) {
Object span = spans[spanIdx];
@@ -653,33 +674,170 @@ public class ReactEditText extends AppCompatEditText
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());
// TODO: Replace with Predicate<T> and lambdas once Java 8 builds in OSS
interface SpanPredicate<T> {
boolean test(T span);
}
/**
* Remove spans from the SpannableStringBuilder which can be represented by TextAppearance
* attributes on the underlying EditText. This works around instability on Samsung devices with
* the presence of spans https://github.com/facebook/react-native/issues/35936 (S318090)
*/
private void stripStyleEquivalentSpans(SpannableStringBuilder sb) {
stripSpansOfKind(
sb,
ReactAbsoluteSizeSpan.class,
new SpanPredicate<ReactAbsoluteSizeSpan>() {
@Override
public boolean test(ReactAbsoluteSizeSpan span) {
return span.getSize() == mTextAttributes.getEffectiveFontSize();
}
});
stripSpansOfKind(
sb,
ReactBackgroundColorSpan.class,
new SpanPredicate<ReactBackgroundColorSpan>() {
@Override
public boolean test(ReactBackgroundColorSpan span) {
return span.getBackgroundColor() == mReactBackgroundManager.getBackgroundColor();
}
});
stripSpansOfKind(
sb,
ReactForegroundColorSpan.class,
new SpanPredicate<ReactForegroundColorSpan>() {
@Override
public boolean test(ReactForegroundColorSpan span) {
return span.getForegroundColor() == getCurrentTextColor();
}
});
stripSpansOfKind(
sb,
ReactStrikethroughSpan.class,
new SpanPredicate<ReactStrikethroughSpan>() {
@Override
public boolean test(ReactStrikethroughSpan span) {
return (getPaintFlags() & Paint.STRIKE_THRU_TEXT_FLAG) != 0;
}
});
stripSpansOfKind(
sb,
ReactUnderlineSpan.class,
new SpanPredicate<ReactUnderlineSpan>() {
@Override
public boolean test(ReactUnderlineSpan span) {
return (getPaintFlags() & Paint.UNDERLINE_TEXT_FLAG) != 0;
}
});
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
stripSpansOfKind(
sb,
CustomLetterSpacingSpan.class,
new SpanPredicate<CustomLetterSpacingSpan>() {
@Override
public boolean test(CustomLetterSpacingSpan span) {
return span.getSpacing() == mTextAttributes.getEffectiveLetterSpacing();
}
});
}
stripSpansOfKind(
sb,
CustomStyleSpan.class,
new SpanPredicate<CustomStyleSpan>() {
@Override
public boolean test(CustomStyleSpan span) {
return span.getStyle() == mFontStyle
&& Objects.equals(span.getFontFamily(), mFontFamily)
&& span.getWeight() == mFontWeight
&& Objects.equals(span.getFontFeatureSettings(), getFontFeatureSettings());
}
});
}
private <T> void stripSpansOfKind(
SpannableStringBuilder sb, Class<T> clazz, SpanPredicate<T> shouldStrip) {
T[] spans = sb.getSpans(0, sb.length(), clazz);
for (T span : spans) {
if (shouldStrip.test(span)) {
sb.removeSpan(span);
}
}
}
private void stripAbsoluteSizeSpans(SpannableStringBuilder sb) {
// We have already set a font size on the EditText itself. We can safely remove sizing spans
// which are the same as the set font size, and not otherwise overlapped.
final int effectiveFontSize = mTextAttributes.getEffectiveFontSize();
ReactAbsoluteSizeSpan[] spans = sb.getSpans(0, sb.length(), ReactAbsoluteSizeSpan.class);
/**
* Copy styles represented as attributes to the underlying span, for later measurement or other
* usage outside the ReactEditText.
*/
private void addSpansFromStyleAttributes(SpannableStringBuilder workingText) {
int spanFlags = Spannable.SPAN_INCLUSIVE_INCLUSIVE;
outerLoop:
for (ReactAbsoluteSizeSpan span : spans) {
ReactAbsoluteSizeSpan[] overlappingSpans =
sb.getSpans(sb.getSpanStart(span), sb.getSpanEnd(span), ReactAbsoluteSizeSpan.class);
// Set all bits for SPAN_PRIORITY so that this span has the highest possible priority
// (least precedence). This ensures the span is behind any overlapping spans.
spanFlags |= Spannable.SPAN_PRIORITY;
for (ReactAbsoluteSizeSpan overlappingSpan : overlappingSpans) {
if (span.getSize() != effectiveFontSize) {
continue outerLoop;
}
workingText.setSpan(
new ReactAbsoluteSizeSpan(mTextAttributes.getEffectiveFontSize()),
0,
workingText.length(),
spanFlags);
workingText.setSpan(
new ReactForegroundColorSpan(getCurrentTextColor()), 0, workingText.length(), spanFlags);
int backgroundColor = mReactBackgroundManager.getBackgroundColor();
if (backgroundColor != Color.TRANSPARENT) {
workingText.setSpan(
new ReactBackgroundColorSpan(backgroundColor), 0, workingText.length(), spanFlags);
}
if ((getPaintFlags() & Paint.STRIKE_THRU_TEXT_FLAG) != 0) {
workingText.setSpan(new ReactStrikethroughSpan(), 0, workingText.length(), spanFlags);
}
if ((getPaintFlags() & Paint.UNDERLINE_TEXT_FLAG) != 0) {
workingText.setSpan(new ReactUnderlineSpan(), 0, workingText.length(), spanFlags);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
float effectiveLetterSpacing = mTextAttributes.getEffectiveLetterSpacing();
if (!Float.isNaN(effectiveLetterSpacing)) {
workingText.setSpan(
new CustomLetterSpacingSpan(effectiveLetterSpacing),
0,
workingText.length(),
spanFlags);
}
}
sb.removeSpan(span);
if (mFontStyle != UNSET
|| mFontWeight != UNSET
|| mFontFamily != null
|| getFontFeatureSettings() != null) {
workingText.setSpan(
new CustomStyleSpan(
mFontStyle,
mFontWeight,
getFontFeatureSettings(),
mFontFamily,
getContext().getAssets()),
0,
workingText.length(),
spanFlags);
}
float lineHeight = mTextAttributes.getEffectiveLineHeight();
if (!Float.isNaN(lineHeight)) {
workingText.setSpan(new CustomLineHeightSpan(lineHeight), 0, workingText.length(), spanFlags);
}
}
@@ -699,73 +857,6 @@ 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 (!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);
}
@@ -1030,7 +1121,9 @@ public class ReactEditText extends AppCompatEditText
float effectiveLetterSpacing = mTextAttributes.getEffectiveLetterSpacing();
if (!Float.isNaN(effectiveLetterSpacing)) {
setLetterSpacing(effectiveLetterSpacing);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
setLetterSpacing(effectiveLetterSpacing);
}
}
}
@@ -1045,7 +1138,7 @@ public class ReactEditText extends AppCompatEditText
* 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) {
private void updateCachedSpannable() {
// Noops in non-Fabric
if (mFabricViewStateManager == null || !mFabricViewStateManager.hasStateWrapper()) {
return;
@@ -1055,12 +1148,6 @@ public class ReactEditText extends AppCompatEditText
return;
}
if (resetStyles) {
mIsSettingTextFromCacheUpdate = true;
addSpansForMeasurement(getText());
mIsSettingTextFromCacheUpdate = false;
}
Editable currentText = getText();
boolean haveText = currentText != null && currentText.length() > 0;
@@ -1118,11 +1205,9 @@ public class ReactEditText extends AppCompatEditText
// 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);
}
addSpansFromStyleAttributes(sb);
TextLayoutManager.setCachedSpannabledForTag(getId(), sb);
}
@@ -1137,7 +1222,7 @@ public class ReactEditText extends AppCompatEditText
private class TextWatcherDelegator implements TextWatcher {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
if (!mIsSettingTextFromCacheUpdate && !mIsSettingTextFromJS && mListeners != null) {
if (!mIsSettingTextFromJS && mListeners != null) {
for (TextWatcher listener : mListeners) {
listener.beforeTextChanged(s, start, count, after);
}
@@ -1151,23 +1236,20 @@ public class ReactEditText extends AppCompatEditText
TAG, "onTextChanged[" + getId() + "]: " + s + " " + start + " " + before + " " + count);
}
if (!mIsSettingTextFromCacheUpdate) {
if (!mIsSettingTextFromJS && mListeners != null) {
for (TextWatcher listener : mListeners) {
listener.onTextChanged(s, start, before, count);
}
if (!mIsSettingTextFromJS && mListeners != null) {
for (TextWatcher listener : mListeners) {
listener.onTextChanged(s, start, before, count);
}
updateCachedSpannable(
!mIsSettingTextFromJS && !mIsSettingTextFromState && start == 0 && before == 0);
}
updateCachedSpannable();
onContentSizeChange();
}
@Override
public void afterTextChanged(Editable s) {
if (!mIsSettingTextFromCacheUpdate && !mIsSettingTextFromJS && mListeners != null) {
if (!mIsSettingTextFromJS && mListeners != null) {
for (TextWatcher listener : mListeners) {
listener.afterTextChanged(s);
}
@@ -13,6 +13,7 @@ import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.BlendMode;
import android.graphics.BlendModeColorFilter;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.os.Build;
@@ -67,6 +68,7 @@ import com.facebook.react.views.text.DefaultStyleValuesUtil;
import com.facebook.react.views.text.ReactBaseTextShadowNode;
import com.facebook.react.views.text.ReactTextUpdate;
import com.facebook.react.views.text.ReactTextViewManagerCallback;
import com.facebook.react.views.text.ReactTypefaceUtils;
import com.facebook.react.views.text.TextAttributeProps;
import com.facebook.react.views.text.TextInlineImageSpan;
import com.facebook.react.views.text.TextLayoutManager;
@@ -404,6 +406,11 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
view.setFontStyle(fontStyle);
}
@ReactProp(name = ViewProps.FONT_VARIANT)
public void setFontVariant(ReactEditText view, @Nullable ReadableArray fontVariant) {
view.setFontFeatureSettings(ReactTypefaceUtils.parseFontVariant(fontVariant));
}
@ReactProp(name = ViewProps.INCLUDE_FONT_PADDING, defaultBoolean = true)
public void setIncludeFontPadding(ReactEditText view, boolean includepad) {
view.setIncludeFontPadding(includepad);
@@ -925,6 +932,20 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
view.setAutoFocus(autoFocus);
}
@ReactProp(name = ViewProps.TEXT_DECORATION_LINE)
public void setTextDecorationLine(ReactEditText view, @Nullable String textDecorationLineString) {
view.setPaintFlags(
view.getPaintFlags() & ~(Paint.STRIKE_THRU_TEXT_FLAG | Paint.UNDERLINE_TEXT_FLAG));
for (String token : textDecorationLineString.split(" ")) {
if (token.equals("underline")) {
view.setPaintFlags(view.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG);
} else if (token.equals("line-through")) {
view.setPaintFlags(view.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
}
}
}
@ReactPropGroup(
names = {
ViewProps.BORDER_WIDTH,
@@ -1322,9 +1343,6 @@ 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"));
@@ -1333,8 +1351,7 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
state.getInt("mostRecentEventCount"),
TextAttributeProps.getTextAlignment(props, TextLayoutManager.isRTL(attributedString)),
textBreakStrategy,
TextAttributeProps.getJustificationMode(props),
containsMultipleFragments);
TextAttributeProps.getJustificationMode(props, currentJustificationMode));
}
public Object getReactTextUpdate(ReactEditText view, ReactStylesDiffMap props, MapBuffer state) {
@@ -1355,9 +1372,6 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
TextLayoutManagerMapBuffer.getOrCreateSpannableForText(
view.getContext(), attributedString, mReactTextViewManagerCallback);
boolean containsMultipleFragments =
attributedString.getMapBuffer(TextLayoutManagerMapBuffer.AS_KEY_FRAGMENTS).getCount() > 1;
int textBreakStrategy =
TextAttributeProps.getTextBreakStrategy(
paragraphAttributes.getString(TextLayoutManagerMapBuffer.PA_KEY_TEXT_BREAK_STRATEGY));
@@ -1368,7 +1382,6 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
TextAttributeProps.getTextAlignment(
props, TextLayoutManagerMapBuffer.isRTL(attributedString)),
textBreakStrategy,
TextAttributeProps.getJustificationMode(props),
containsMultipleFragments);
TextAttributeProps.getJustificationMode(props, currentJustificationMode));
}
}
@@ -19,6 +19,7 @@ public class ReactViewBackgroundManager {
private @Nullable ReactViewBackgroundDrawable mReactBackgroundDrawable;
private View mView;
private int mColor = Color.TRANSPARENT;
public ReactViewBackgroundManager(View view) {
this.mView = view;
@@ -56,6 +57,10 @@ public class ReactViewBackgroundManager {
}
}
public int getBackgroundColor() {
return mColor;
}
public void setBorderWidth(int position, float width) {
getOrCreateReactViewBackground().setBorderWidth(position, width);
}