diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/common/IntConstants.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/common/IntConstants.kt new file mode 100644 index 00000000000000..e25f3d0564737f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/common/IntConstants.kt @@ -0,0 +1,14 @@ +package com.facebook.react.common + +/** + * General-purpose integer constants + */ +internal object IntConstants { + /** + * Some types have built-in support for representing a "missing" or "unset" value, for example + * NaN in the case of floating point numbers or null in the case of object references. Integers + * don't have such a special value. When an integer represent an inherently non-negative value, + * we use a special negative value to mark it as "unset". + */ + const val UNSET: Int = -1 +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/common/assets/ReactFontManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/common/assets/ReactFontManager.java index 7e7df01bd140ff..f2fc361501a7ff 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/common/assets/ReactFontManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/common/assets/ReactFontManager.java @@ -15,6 +15,8 @@ import androidx.annotation.Nullable; import androidx.core.content.res.ResourcesCompat; import com.facebook.infer.annotation.Nullsafe; +import com.facebook.react.common.IntConstants; + import java.util.HashMap; import java.util.Map; @@ -167,8 +169,6 @@ public static class TypefaceStyle { public static final int BOLD = 700; public static final int NORMAL = 400; - public static final int UNSET = -1; - private static final int MIN_WEIGHT = 1; private static final int MAX_WEIGHT = 1000; @@ -177,11 +177,11 @@ public static class TypefaceStyle { public TypefaceStyle(int weight, boolean italic) { mItalic = italic; - mWeight = weight == UNSET ? NORMAL : weight; + mWeight = weight == IntConstants.UNSET ? NORMAL : weight; } public TypefaceStyle(int style) { - if (style == UNSET) { + if (style == IntConstants.UNSET) { style = Typeface.NORMAL; } @@ -194,12 +194,12 @@ public TypefaceStyle(int style) { * existing weight bit in `style` will be used. */ public TypefaceStyle(int style, int weight) { - if (style == UNSET) { + if (style == IntConstants.UNSET) { style = Typeface.NORMAL; } mItalic = (style & Typeface.ITALIC) != 0; - mWeight = weight == UNSET ? (style & Typeface.BOLD) != 0 ? BOLD : NORMAL : weight; + mWeight = weight == IntConstants.UNSET ? (style & Typeface.BOLD) != 0 ? BOLD : NORMAL : weight; } public int getNearestStyle() { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/config/ReactFeatureFlags.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/config/ReactFeatureFlags.java index 250772c9015e91..ee4882d37f5963 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/config/ReactFeatureFlags.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/config/ReactFeatureFlags.java @@ -173,4 +173,7 @@ public class ReactFeatureFlags { * when there is work to do. */ public static boolean enableOnDemandReactChoreographer = false; + + /** Enables the new unified {@link android.text.Spannable} building logic. */ + public static boolean enableSpannableBuildingUnification = false; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/BasicTextAttributeProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/BasicTextAttributeProvider.kt new file mode 100644 index 00000000000000..54b6591ebaf3da --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/BasicTextAttributeProvider.kt @@ -0,0 +1,41 @@ +package com.facebook.react.views.text + +import com.facebook.react.uimanager.ReactAccessibilityDelegate + +/** + * Interface for an entity providing basic text attributes of a text node/fragment. "Basic" means + * that they can be provided trivially, without processing the parent element. + */ +internal interface BasicTextAttributeProvider { + val role: ReactAccessibilityDelegate.Role? + + val accessibilityRole: ReactAccessibilityDelegate.AccessibilityRole? + + val isBackgroundColorSet: Boolean + + val backgroundColor: Int + + val isColorSet: Boolean + + val color: Int + + val fontStyle: Int + + val fontWeight: Int + + val fontFamily: String? + + val fontFeatureSettings: String? + + val isUnderlineTextDecorationSet: Boolean + + val isLineThroughTextDecorationSet: Boolean + + val textShadowOffsetDx: Float + + val textShadowOffsetDy: Float + + val textShadowRadius: Float + + val textShadowColor: Int +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomStyleSpan.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomStyleSpan.java index 51fd4dd0149c77..6704f2684d9567 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomStyleSpan.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomStyleSpan.java @@ -14,6 +14,7 @@ import android.text.style.MetricAffectingSpan; import androidx.annotation.Nullable; import com.facebook.infer.annotation.Nullsafe; +import com.facebook.react.common.IntConstants; import com.facebook.react.common.assets.ReactFontManager; @Nullsafe(Nullsafe.Mode.LOCAL) @@ -61,11 +62,11 @@ public void updateMeasureState(TextPaint paint) { } public int getStyle() { - return mStyle == ReactFontManager.TypefaceStyle.UNSET ? Typeface.NORMAL : mStyle; + return mStyle == IntConstants.UNSET ? Typeface.NORMAL : mStyle; } public int getWeight() { - return mWeight == ReactFontManager.TypefaceStyle.UNSET + return mWeight == IntConstants.UNSET ? ReactFontManager.TypefaceStyle.NORMAL : mWeight; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EffectiveTextAttributeProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EffectiveTextAttributeProvider.kt new file mode 100644 index 00000000000000..a9179ce7c0cf7c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EffectiveTextAttributeProvider.kt @@ -0,0 +1,19 @@ +package com.facebook.react.views.text + +import com.facebook.react.common.IntConstants.UNSET + +/** + * Interface for an entity providing effective text attributes of a text node/fragment + */ +internal interface EffectiveTextAttributeProvider : BasicTextAttributeProvider { + val textTransform: TextTransform + + val effectiveLetterSpacing: Float + + /** + * @return The effective font size, or [UNSET] if not set + */ + val effectiveFontSize: Int + + val effectiveLineHeight: Float +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/HierarchicTextAttributeProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/HierarchicTextAttributeProvider.kt new file mode 100644 index 00000000000000..da8568e06330f3 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/HierarchicTextAttributeProvider.kt @@ -0,0 +1,54 @@ +package com.facebook.react.views.text + +import com.facebook.react.common.IntConstants + +/** + * Implementation of [EffectiveTextAttributeProvider] that provides effective text + * attributes based on a [ReactBaseTextShadowNode] instance and its parent. + */ +internal class HierarchicTextAttributeProvider( + private val textShadowNode: ReactBaseTextShadowNode, + private val parentTextAttributes: TextAttributes?, + private val textAttributes: TextAttributes +) : EffectiveTextAttributeProvider, BasicTextAttributeProvider by textShadowNode { + override val textTransform: TextTransform + get() = textAttributes.textTransform + + override val effectiveLetterSpacing: Float + get() { + val letterSpacing = textAttributes.effectiveLetterSpacing + + val isParentLetterSpacingDifferent = + parentTextAttributes == null || parentTextAttributes.effectiveLetterSpacing != letterSpacing + + return if (!letterSpacing.isNaN() && isParentLetterSpacingDifferent) { + letterSpacing + } else { + Float.NaN + } + } + + override val effectiveFontSize: Int + get() { + val fontSize = textAttributes.effectiveFontSize + + return if (parentTextAttributes == null || parentTextAttributes.effectiveFontSize != fontSize) { + fontSize + } else { + IntConstants.UNSET + } + } + + override val effectiveLineHeight: Float + get() { + val lineHeight = textAttributes.effectiveLineHeight + val isParentLineHeightDifferent = + parentTextAttributes == null || parentTextAttributes.effectiveLineHeight != lineHeight + + return if (!lineHeight.isNaN() && isParentLineHeightDifferent) { + lineHeight + } else { + Float.NaN + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index c752e04be06458..8249cd73b2d02d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -20,8 +20,9 @@ import com.facebook.infer.annotation.Assertions; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.common.IntConstants; import com.facebook.react.common.ReactConstants; -import com.facebook.react.common.assets.ReactFontManager; +import com.facebook.react.config.ReactFeatureFlags; import com.facebook.react.uimanager.IllegalViewOperationException; import com.facebook.react.uimanager.LayoutShadowNode; import com.facebook.react.uimanager.NativeViewHierarchyOptimizer; @@ -49,13 +50,12 @@ *

This also node calculates {@link Spannable} object based on subnodes of the same type, which * can be used in concrete classes to feed native views and compute layout. */ -public abstract class ReactBaseTextShadowNode extends LayoutShadowNode { +public abstract class ReactBaseTextShadowNode extends LayoutShadowNode implements BasicTextAttributeProvider { // Use a direction weak character so the placeholder doesn't change the direction of the previous // character. // https://en.wikipedia.org/wiki/Bi-directional_text#weak_characters private static final String INLINE_VIEW_PLACEHOLDER = "0"; - public static final int UNSET = ReactFontManager.TypefaceStyle.UNSET; public static final String PROP_SHADOW_OFFSET = "textShadowOffset"; public static final String PROP_SHADOW_OFFSET_WIDTH = "width"; @@ -70,6 +70,37 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode { protected @Nullable ReactTextViewManagerCallback mReactTextViewManagerCallback; private static void buildSpannedFromShadowNode( + ReactBaseTextShadowNode textShadowNode, + SpannableStringBuilder sb, + List ops, + @Nullable TextAttributes parentTextAttributes, + boolean supportsInlineViews, + @Nullable Map inlineViews, + int start) { + if (ReactFeatureFlags.enableSpannableBuildingUnification) { + buildSpannedFromShadowNodeUnified( + textShadowNode, + sb, + ops, + parentTextAttributes, + supportsInlineViews, + inlineViews, + start + ); + } else { + buildSpannedFromShadowNodeDuplicated( + textShadowNode, + sb, + ops, + parentTextAttributes, + supportsInlineViews, + inlineViews, + start + ); + } + } + + private static void buildSpannedFromShadowNodeDuplicated( ReactBaseTextShadowNode textShadowNode, SpannableStringBuilder sb, List ops, @@ -93,7 +124,7 @@ private static void buildSpannedFromShadowNode( TextTransform.apply( ((ReactRawTextShadowNode) child).getText(), textAttributes.getTextTransform())); } else if (child instanceof ReactBaseTextShadowNode) { - buildSpannedFromShadowNode( + buildSpannedFromShadowNodeDuplicated( (ReactBaseTextShadowNode) child, sb, ops, @@ -177,8 +208,8 @@ private static void buildSpannedFromShadowNode( || parentTextAttributes.getEffectiveFontSize() != effectiveFontSize) { ops.add(new SetSpanOperation(start, end, new ReactAbsoluteSizeSpan(effectiveFontSize))); } - if (textShadowNode.mFontStyle != UNSET - || textShadowNode.mFontWeight != UNSET + if (textShadowNode.mFontStyle != IntConstants.UNSET + || textShadowNode.mFontWeight != IntConstants.UNSET || textShadowNode.mFontFamily != null) { ops.add( new SetSpanOperation( @@ -221,6 +252,93 @@ private static void buildSpannedFromShadowNode( } } + private static void buildSpannedFromShadowNodeUnified( + ReactBaseTextShadowNode textShadowNode, + SpannableStringBuilder sb, + List ops, + @Nullable TextAttributes parentTextAttributes, + boolean supportsInlineViews, + @Nullable Map inlineViews, + int start) { + + TextAttributes textAttributes; + if (parentTextAttributes != null) { + textAttributes = parentTextAttributes.applyChild(textShadowNode.mTextAttributes); + } else { + textAttributes = textShadowNode.mTextAttributes; + } + + final var textAttributeProvider = new HierarchicTextAttributeProvider(textShadowNode, parentTextAttributes, textAttributes); + + for (int i = 0, length = textShadowNode.getChildCount(); i < length; i++) { + ReactShadowNode child = textShadowNode.getChildAt(i); + + if (child instanceof ReactRawTextShadowNode) { + TextLayoutUtils.addText(sb, ((ReactRawTextShadowNode) child).getText(), textAttributeProvider); + } else if (child instanceof ReactBaseTextShadowNode) { + buildSpannedFromShadowNodeUnified( + (ReactBaseTextShadowNode) child, + sb, + ops, + textAttributes, + supportsInlineViews, + inlineViews, + sb.length()); + } else if (child instanceof ReactTextInlineImageShadowNode) { + addInlineImageSpan(ops, sb, (ReactTextInlineImageShadowNode) child); + } else if (supportsInlineViews) { + addInlineViewPlaceholderSpan(ops, sb, child); + + inlineViews.put(child.getReactTag(), child); + } else { + throw new IllegalViewOperationException( + "Unexpected view type nested under a or node: " + child.getClass()); + } + child.markUpdateSeen(); + } + int end = sb.length(); + if (end >= start) { + final int reactTag = textShadowNode.getReactTag(); + + TextLayoutUtils.addApplicableTextAttributeSpans( + ops, textAttributeProvider, reactTag, textShadowNode.getThemedContext(), start, end); + } + } + + private static void addInlineImageSpan(List ops, SpannableStringBuilder sb, + ReactTextInlineImageShadowNode child) { + // We make the image take up 1 character in the span and put a corresponding character into + // the text so that the image doesn't run over any following text. + sb.append(INLINE_VIEW_PLACEHOLDER); + ops.add(new SetSpanOperation(sb.length() - INLINE_VIEW_PLACEHOLDER.length(), sb.length(), + child.buildInlineImageSpan())); + } + + private static void addInlineViewPlaceholderSpan(List ops, SpannableStringBuilder sb, + ReactShadowNode child) { + YogaValue widthValue = child.getStyleWidth(); + YogaValue heightValue = child.getStyleHeight(); + + float width; + float height; + if (widthValue.unit != YogaUnit.POINT || heightValue.unit != YogaUnit.POINT) { + // If the measurement of the child isn't calculated, we calculate the layout for the + // view using Yoga + child.calculateLayout(); + width = child.getLayoutWidth(); + height = child.getLayoutHeight(); + } else { + width = widthValue.value; + height = heightValue.value; + } + + // We make the inline view take up 1 character in the span and put a corresponding character into the text so that + // the inline view doesn't run over any following text. + sb.append(INLINE_VIEW_PLACEHOLDER); + + TextLayoutUtils.addInlineViewPlaceholderSpan(ops, sb, child.getReactTag(), width, height); + } + // `nativeViewHierarchyOptimizer` can be `null` as long as `supportsInlineViews` is `false`. protected Spannable spannedFromShadowNode( ReactBaseTextShadowNode textShadowNode, @@ -307,7 +425,7 @@ protected Spannable spannedFromShadowNode( protected @Nullable AccessibilityRole mAccessibilityRole = null; protected @Nullable Role mRole = null; - protected int mNumberOfLines = UNSET; + protected int mNumberOfLines = IntConstants.UNSET; protected int mTextAlign = Gravity.NO_GRAVITY; protected int mTextBreakStrategy = Layout.BREAK_STRATEGY_HIGH_QUALITY; protected int mHyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE; @@ -329,9 +447,9 @@ protected Spannable spannedFromShadowNode( * mFontStyle can be {@link Typeface#NORMAL} or {@link Typeface#ITALIC}. mFontWeight can be {@link * Typeface#NORMAL} or {@link Typeface#BOLD}. */ - protected int mFontStyle = UNSET; + protected int mFontStyle = IntConstants.UNSET; - protected int mFontWeight = UNSET; + protected int mFontWeight = IntConstants.UNSET; /** * NB: If a font family is used that does not have a style in a certain Android version (ie. * monospace bold pre Android 5.0), that style (ie. bold) will not be inherited by nested Text @@ -384,9 +502,9 @@ private int getTextAlign() { return textAlign; } - @ReactProp(name = ViewProps.NUMBER_OF_LINES, defaultInt = UNSET) + @ReactProp(name = ViewProps.NUMBER_OF_LINES, defaultInt = IntConstants.UNSET) public void setNumberOfLines(int numberOfLines) { - mNumberOfLines = numberOfLines == 0 ? UNSET : numberOfLines; + mNumberOfLines = numberOfLines == 0 ? IntConstants.UNSET : numberOfLines; markUpdated(); } @@ -452,6 +570,11 @@ public void setFontSize(float fontSize) { markUpdated(); } + @Override + public int getColor() { + return mColor; + } + @ReactProp(name = ViewProps.COLOR, customType = "Color") public void setColor(@Nullable Integer color) { mIsColorSet = (color != null); @@ -461,6 +584,16 @@ public void setColor(@Nullable Integer color) { markUpdated(); } + @Override + public boolean isColorSet() { + return mIsColorSet; + } + + @Override + public int getBackgroundColor() { + return mBackgroundColor; + } + @ReactProp(name = ViewProps.BACKGROUND_COLOR, customType = "Color") public void setBackgroundColor(@Nullable Integer color) { // Background color needs to be handled here for virtual nodes so it can be incorporated into @@ -476,6 +609,16 @@ public void setBackgroundColor(@Nullable Integer color) { } } + @Override + public boolean isBackgroundColorSet() { + return mIsBackgroundColorSet; + } + + @Override + public @Nullable AccessibilityRole getAccessibilityRole() { + return mAccessibilityRole; + } + @ReactProp(name = ViewProps.ACCESSIBILITY_ROLE) public void setAccessibilityRole(@Nullable String accessibilityRole) { if (isVirtual()) { @@ -484,6 +627,11 @@ public void setAccessibilityRole(@Nullable String accessibilityRole) { } } + @Override + public @Nullable Role getRole() { + return mRole; + } + @ReactProp(name = ViewProps.ROLE) public void setRole(@Nullable String role) { if (isVirtual()) { @@ -492,12 +640,22 @@ public void setRole(@Nullable String role) { } } + @Override + public String getFontFamily() { + return mFontFamily; + } + @ReactProp(name = ViewProps.FONT_FAMILY) public void setFontFamily(@Nullable String fontFamily) { mFontFamily = fontFamily; markUpdated(); } + @Override + public int getFontWeight() { + return mFontWeight; + } + @ReactProp(name = ViewProps.FONT_WEIGHT) public void setFontWeight(@Nullable String fontWeightString) { int fontWeight = ReactTypefaceUtils.parseFontWeight(fontWeightString); @@ -517,6 +675,16 @@ public void setFontVariant(@Nullable ReadableArray fontVariantArray) { } } + @Override + public String getFontFeatureSettings() { + return mFontFeatureSettings; + } + + @Override + public int getFontStyle() { + return mFontStyle; + } + @ReactProp(name = ViewProps.FONT_STYLE) public void setFontStyle(@Nullable String fontStyleString) { int fontStyle = ReactTypefaceUtils.parseFontStyle(fontStyleString); @@ -547,6 +715,16 @@ public void setTextDecorationLine(@Nullable String textDecorationLineString) { markUpdated(); } + @Override + public boolean isUnderlineTextDecorationSet() { + return mIsUnderlineTextDecorationSet; + } + + @Override + public boolean isLineThroughTextDecorationSet() { + return mIsLineThroughTextDecorationSet; + } + @ReactProp(name = ViewProps.TEXT_BREAK_STRATEGY) public void setTextBreakStrategy(@Nullable String textBreakStrategy) { if (textBreakStrategy == null || "highQuality".equals(textBreakStrategy)) { @@ -584,6 +762,21 @@ public void setTextShadowOffset(ReadableMap offsetMap) { markUpdated(); } + @Override + public float getTextShadowOffsetDx() { + return mTextShadowOffsetDx; + } + + @Override + public float getTextShadowOffsetDy() { + return mTextShadowOffsetDy; + } + + @Override + public float getTextShadowRadius() { + return mTextShadowRadius; + } + @ReactProp(name = PROP_SHADOW_RADIUS, defaultInt = 1) public void setTextShadowRadius(float textShadowRadius) { if (textShadowRadius != mTextShadowRadius) { @@ -592,6 +785,11 @@ public void setTextShadowRadius(float textShadowRadius) { } } + @Override + public int getTextShadowColor() { + return mTextShadowColor; + } + @ReactProp(name = PROP_SHADOW_COLOR, defaultInt = DEFAULT_TEXT_SHADOW_COLOR, customType = "Color") public void setTextShadowColor(int textShadowColor) { if (textShadowColor != mTextShadowColor) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java index d53f18a5aa3289..0e7b5fd27fa0fa 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java @@ -23,6 +23,7 @@ import com.facebook.react.bridge.ReactSoftExceptionLogger; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; +import com.facebook.react.common.IntConstants; import com.facebook.react.uimanager.NativeViewHierarchyOptimizer; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ReactShadowNode; @@ -80,7 +81,7 @@ public long measure( int minimumFontSize = (int) Math.max(mMinimumFontScale * initialFontSize, PixelUtil.toPixelFromDIP(4)); while (currentFontSize > minimumFontSize - && (mNumberOfLines != UNSET && layout.getLineCount() > mNumberOfLines + && (mNumberOfLines != IntConstants.UNSET && layout.getLineCount() > mNumberOfLines || heightMode != YogaMeasureMode.UNDEFINED && layout.getHeight() > height)) { // TODO: We could probably use a smarter algorithm here. This will require 0(n) // measurements @@ -122,7 +123,7 @@ public long measure( } final int lineCount = - mNumberOfLines == UNSET + mNumberOfLines == IntConstants.UNSET ? layout.getLineCount() : Math.min(mNumberOfLines, layout.getLineCount()); @@ -327,7 +328,7 @@ public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) { ReactTextUpdate reactTextUpdate = new ReactTextUpdate( mPreparedSpannableText, - UNSET, + IntConstants.UNSET, mContainsImages, getPadding(Spacing.START), getPadding(Spacing.TOP), diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java index 0802ec3622a1bd..1bfab32967cb93 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.java @@ -7,11 +7,11 @@ package com.facebook.react.views.text; -import static com.facebook.react.views.text.TextAttributeProps.UNSET; - import android.text.Layout; import android.text.Spannable; +import com.facebook.react.common.IntConstants; + /** * Class that contains the data needed for a text update. Used by both and * VisibleForTesting from {@link TextInputEventsTestCase}. @@ -67,10 +67,10 @@ public ReactTextUpdate( text, jsEventCounter, containsImages, - UNSET, - UNSET, - UNSET, - UNSET, + IntConstants.UNSET, + IntConstants.UNSET, + IntConstants.UNSET, + IntConstants.UNSET, textAlign, textBreakStrategy, justificationMode); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 0c1aa6a19cd555..6c4d8f10574d32 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -7,8 +7,6 @@ package com.facebook.react.views.text; -import static com.facebook.react.views.text.TextAttributeProps.UNSET; - import android.content.Context; import android.graphics.drawable.Drawable; import android.os.Build; @@ -35,6 +33,7 @@ import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; +import com.facebook.react.common.IntConstants; import com.facebook.react.common.ReactConstants; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ReactCompoundView; @@ -376,10 +375,10 @@ public void setText(ReactTextUpdate update) { // In Fabric padding is set by the update of Layout Metrics and not as part of the "setText" // operation // TODO T56559197: remove this condition when we migrate 100% to Fabric - if (paddingLeft != UNSET - && paddingTop != UNSET - && paddingRight != UNSET - && paddingBottom != UNSET) { + if (paddingLeft != IntConstants.UNSET + && paddingTop != IntConstants.UNSET + && paddingRight != IntConstants.UNSET + && paddingBottom != IntConstants.UNSET) { setPadding( (int) Math.floor(paddingLeft), diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.java index 64fe5e36c0eb0d..da103426a9f5f4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.java @@ -13,6 +13,7 @@ import androidx.annotation.Nullable; import com.facebook.infer.annotation.Nullsafe; import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.common.IntConstants; import com.facebook.react.common.assets.ReactFontManager; import java.util.ArrayList; import java.util.List; @@ -45,7 +46,7 @@ public static int parseFontWeight(@Nullable String fontWeightString) { return 900; } } - return ReactFontManager.TypefaceStyle.UNSET; + return IntConstants.UNSET; } public static int parseFontStyle(@Nullable String fontStyleString) { @@ -57,7 +58,7 @@ public static int parseFontStyle(@Nullable String fontStyleString) { return Typeface.NORMAL; } } - return ReactFontManager.TypefaceStyle.UNSET; + return IntConstants.UNSET; } public static @Nullable String parseFontVariant(@Nullable ReadableArray fontVariantArray) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/SetSpanOperation.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/SetSpanOperation.java index 5df16e4e283822..57649508d54316 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/SetSpanOperation.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/SetSpanOperation.java @@ -12,7 +12,7 @@ import android.text.Spanned; import com.facebook.common.logging.FLog; -class SetSpanOperation { +public class SetSpanOperation { private static final String TAG = "SetSpanOperation"; static final int SPAN_MAX_PRIORITY = Spanned.SPAN_PRIORITY >> Spanned.SPAN_PRIORITY_SHIFT; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java index aa450d79fd2111..df384e7d616fc6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java @@ -12,10 +12,12 @@ import android.text.TextUtils; import android.util.LayoutDirection; import android.view.Gravity; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.common.IntConstants; import com.facebook.react.common.ReactConstants; import com.facebook.react.common.mapbuffer.MapBuffer; import com.facebook.react.uimanager.PixelUtil; @@ -30,7 +32,7 @@ // TODO: T63643819 refactor naming of TextAttributeProps to make explicit that this represents // TextAttributes and not TextProps. As part of this refactor extract methods that don't belong to // TextAttributeProps (e.g. TextAlign) -public class TextAttributeProps { +public class TextAttributeProps implements EffectiveTextAttributeProvider { // constants for Text Attributes serialization public static final short TA_KEY_FOREGROUND_COLOR = 0; @@ -61,8 +63,6 @@ public class TextAttributeProps { public static final short TA_KEY_ROLE = 26; public static final short TA_KEY_TEXT_TRANSFORM = 27; - public static final int UNSET = -1; - private static final String PROP_SHADOW_OFFSET = "textShadowOffset"; private static final String PROP_SHADOW_OFFSET_WIDTH = "width"; private static final String PROP_SHADOW_OFFSET_HEIGHT = "height"; @@ -84,16 +84,17 @@ public class TextAttributeProps { protected boolean mIsBackgroundColorSet = false; protected int mBackgroundColor; - protected int mNumberOfLines = UNSET; - protected int mFontSize = UNSET; - protected float mFontSizeInput = UNSET; - protected float mLineHeightInput = UNSET; + protected int mNumberOfLines = IntConstants.UNSET; + protected int mFontSize = IntConstants.UNSET; + protected float mFontSizeInput = IntConstants.UNSET; + protected float mLineHeightInput = IntConstants.UNSET; protected float mLetterSpacingInput = Float.NaN; protected int mTextAlign = Gravity.NO_GRAVITY; - // `UNSET` is -1 and is the same as `LayoutDirection.UNDEFINED` but the symbol isn't available. - protected int mLayoutDirection = UNSET; + // `IntConstants.UNSET` is -1, same as `LayoutDirection.UNDEFINED` (which is a hidden symbol) + protected int mLayoutDirection = IntConstants.UNSET; + @NonNull protected TextTransform mTextTransform = TextTransform.NONE; protected float mTextShadowOffsetDx = 0; @@ -108,8 +109,8 @@ public class TextAttributeProps { protected @Nullable AccessibilityRole mAccessibilityRole = null; protected @Nullable Role mRole = null; - protected int mFontStyle = UNSET; - protected int mFontWeight = UNSET; + protected int mFontStyle = IntConstants.UNSET; + protected int mFontWeight = IntConstants.UNSET; /** * NB: If a font family is used that does not have a style in a certain Android version (ie. * monospace bold pre Android 5.0), that style (ie. bold) will not be inherited by nested Text @@ -233,11 +234,11 @@ public static TextAttributeProps fromMapBuffer(MapBuffer props) { public static TextAttributeProps fromReadableMap(ReactStylesDiffMap props) { TextAttributeProps result = new TextAttributeProps(); - result.setNumberOfLines(getIntProp(props, ViewProps.NUMBER_OF_LINES, UNSET)); - result.setLineHeight(getFloatProp(props, ViewProps.LINE_HEIGHT, UNSET)); + result.setNumberOfLines(getIntProp(props, ViewProps.NUMBER_OF_LINES, IntConstants.UNSET)); + result.setLineHeight(getFloatProp(props, ViewProps.LINE_HEIGHT, IntConstants.UNSET)); result.setLetterSpacing(getFloatProp(props, ViewProps.LETTER_SPACING, Float.NaN)); result.setAllowFontScaling(getBooleanProp(props, ViewProps.ALLOW_FONT_SCALING, true)); - result.setFontSize(getFloatProp(props, ViewProps.FONT_SIZE, UNSET)); + result.setFontSize(getFloatProp(props, ViewProps.FONT_SIZE, IntConstants.UNSET)); result.setColor(props.hasKey(ViewProps.COLOR) ? props.getInt(ViewProps.COLOR, 0) : null); result.setColor( props.hasKey(ViewProps.FOREGROUND_COLOR) @@ -343,6 +344,7 @@ private static float getFloatProp(ReactStylesDiffMap mProps, String name, float // Returns a line height which takes into account the requested line height // and the height of the inline images. + @Override public float getEffectiveLineHeight() { boolean useInlineViewHeight = !Float.isNaN(mLineHeight) @@ -352,12 +354,12 @@ public float getEffectiveLineHeight() { } private void setNumberOfLines(int numberOfLines) { - mNumberOfLines = numberOfLines == 0 ? UNSET : numberOfLines; + mNumberOfLines = numberOfLines == 0 ? IntConstants.UNSET : numberOfLines; } private void setLineHeight(float lineHeight) { mLineHeightInput = lineHeight; - if (lineHeight == UNSET) { + if (lineHeight == IntConstants.UNSET) { mLineHeight = Float.NaN; } else { mLineHeight = @@ -371,6 +373,12 @@ private void setLetterSpacing(float letterSpacing) { mLetterSpacingInput = letterSpacing; } + @Override + @NonNull + public TextTransform getTextTransform() { + return mTextTransform; + } + public float getLetterSpacing() { float letterSpacingPixels = mAllowFontScaling @@ -386,6 +394,16 @@ public float getLetterSpacing() { return letterSpacingPixels / mFontSize; } + @Override + public float getEffectiveLetterSpacing() { + return getLetterSpacing(); + } + + @Override + public int getEffectiveFontSize() { + return mFontSize; + } + private void setAllowFontScaling(boolean allowFontScaling) { if (allowFontScaling != mAllowFontScaling) { mAllowFontScaling = allowFontScaling; @@ -397,7 +415,7 @@ private void setAllowFontScaling(boolean allowFontScaling) { private void setFontSize(float fontSize) { mFontSizeInput = fontSize; - if (fontSize != UNSET) { + if (fontSize != IntConstants.UNSET) { fontSize = mAllowFontScaling ? (float) Math.ceil(PixelUtil.toPixelFromSP(fontSize)) @@ -406,6 +424,11 @@ private void setFontSize(float fontSize) { mFontSize = (int) fontSize; } + @Override + public int getColor() { + return mColor; + } + private void setColor(@Nullable Integer color) { mIsColorSet = (color != null); if (mIsColorSet) { @@ -413,6 +436,16 @@ private void setColor(@Nullable Integer color) { } } + @Override + public boolean isColorSet() { + return mIsColorSet; + } + + @Override + public int getBackgroundColor() { + return mBackgroundColor; + } + private void setBackgroundColor(Integer color) { // TODO: Don't apply background color to anchor TextView since it will be applied on the View // directly @@ -424,6 +457,21 @@ private void setBackgroundColor(Integer color) { // } } + @Override + public boolean isBackgroundColorSet() { + return mIsBackgroundColorSet; + } + + @Override + public int getFontStyle() { + return mFontStyle; + } + + @Override + public String getFontFamily() { + return mFontFamily; + } + private void setFontFamily(@Nullable String fontFamily) { mFontFamily = fontFamily; } @@ -526,6 +574,16 @@ private void setFontVariant(@Nullable MapBuffer fontVariant) { mFontFeatureSettings = TextUtils.join(", ", features); } + @Override + public String getFontFeatureSettings() { + return mFontFeatureSettings; + } + + @Override + public int getFontWeight() { + return mFontWeight; + } + private void setFontWeight(@Nullable String fontWeightString) { mFontWeight = ReactTypefaceUtils.parseFontWeight(fontWeightString); } @@ -552,6 +610,16 @@ private void setTextDecorationLine(@Nullable String textDecorationLineString) { } } + @Override + public boolean isUnderlineTextDecorationSet() { + return mIsUnderlineTextDecorationSet; + } + + @Override + public boolean isLineThroughTextDecorationSet() { + return mIsLineThroughTextDecorationSet; + } + private void setTextShadowOffset(ReadableMap offsetMap) { mTextShadowOffsetDx = 0; mTextShadowOffsetDy = 0; @@ -570,10 +638,20 @@ private void setTextShadowOffset(ReadableMap offsetMap) { } } + @Override + public float getTextShadowOffsetDx() { + return mTextShadowOffsetDx; + } + private void setTextShadowOffsetDx(float dx) { mTextShadowOffsetDx = PixelUtil.toPixelFromDIP(dx); } + @Override + public float getTextShadowOffsetDy() { + return mTextShadowOffsetDy; + } + private void setTextShadowOffsetDy(float dy) { mTextShadowOffsetDy = PixelUtil.toPixelFromDIP(dy); } @@ -581,14 +659,14 @@ private void setTextShadowOffsetDy(float dy) { public static int getLayoutDirection(@Nullable String layoutDirection) { int androidLayoutDirection; if (layoutDirection == null || "undefined".equals(layoutDirection)) { - androidLayoutDirection = UNSET; + androidLayoutDirection = IntConstants.UNSET; } else if ("rtl".equals(layoutDirection)) { androidLayoutDirection = LayoutDirection.RTL; } else if ("ltr".equals(layoutDirection)) { androidLayoutDirection = LayoutDirection.LTR; } else { FLog.w(ReactConstants.TAG, "Invalid layoutDirection: " + layoutDirection); - androidLayoutDirection = UNSET; + androidLayoutDirection = IntConstants.UNSET; } return androidLayoutDirection; } @@ -597,12 +675,22 @@ private void setLayoutDirection(@Nullable String layoutDirection) { mLayoutDirection = getLayoutDirection(layoutDirection); } + @Override + public float getTextShadowRadius() { + return mTextShadowRadius; + } + private void setTextShadowRadius(float textShadowRadius) { if (textShadowRadius != mTextShadowRadius) { mTextShadowRadius = textShadowRadius; } } + @Override + public int getTextShadowColor() { + return mTextShadowColor; + } + private void setTextShadowColor(int textShadowColor) { if (textShadowColor != mTextShadowColor) { mTextShadowColor = textShadowColor; @@ -624,6 +712,11 @@ private void setTextTransform(@Nullable String textTransform) { } } + @Override + public AccessibilityRole getAccessibilityRole() { + return mAccessibilityRole; + } + private void setAccessibilityRole(@Nullable String accessibilityRole) { if (accessibilityRole == null) { mAccessibilityRole = null; @@ -632,6 +725,12 @@ private void setAccessibilityRole(@Nullable String accessibilityRole) { } } + @Nullable + @Override + public Role getRole() { + return mRole; + } + private void setRole(@Nullable String role) { if (role == null) { mRole = null; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.java index 8d54a23bbdf6ac..e944d4fb7586e0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.java @@ -7,6 +7,7 @@ package com.facebook.react.views.text; +import androidx.annotation.NonNull; import com.facebook.common.logging.FLog; import com.facebook.react.common.ReactConstants; import com.facebook.react.uimanager.PixelUtil; @@ -29,6 +30,8 @@ public class TextAttributes { private float mLetterSpacing = Float.NaN; private float mMaxFontSizeMultiplier = Float.NaN; private float mHeightOfTallestInlineViewOrImage = Float.NaN; + + @NonNull private TextTransform mTextTransform = TextTransform.UNSET; public TextAttributes() {} @@ -118,7 +121,7 @@ public TextTransform getTextTransform() { return mTextTransform; } - public void setTextTransform(TextTransform textTransform) { + public void setTextTransform(@NonNull TextTransform textTransform) { mTextTransform = textTransform; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java index 93eda5d06b3866..7f70ca933371c9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java @@ -7,8 +7,6 @@ package com.facebook.react.views.text; -import static com.facebook.react.views.text.TextAttributeProps.UNSET; - import android.content.Context; import android.graphics.Color; import android.os.Build; @@ -31,12 +29,15 @@ import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableNativeMap; import com.facebook.react.bridge.WritableArray; +import com.facebook.react.common.IntConstants; import com.facebook.react.common.build.ReactBuildConfig; +import com.facebook.react.config.ReactFeatureFlags; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole; import com.facebook.react.uimanager.ReactAccessibilityDelegate.Role; import com.facebook.react.uimanager.ReactStylesDiffMap; import com.facebook.react.uimanager.ViewProps; +import com.facebook.react.views.text.fragments.BridgeTextFragmentList; import com.facebook.yoga.YogaConstants; import com.facebook.yoga.YogaMeasureMode; import com.facebook.yoga.YogaMeasureOutput; @@ -99,7 +100,19 @@ public static void deleteCachedSpannableForTag(int reactTag) { sTagToSpannableCache.remove(reactTag); } - private static void buildSpannableFromFragment( + private static void buildSpannableFromFragments( + Context context, + ReadableArray fragments, + SpannableStringBuilder sb, + List ops) { + if (ReactFeatureFlags.enableSpannableBuildingUnification) { + buildSpannableFromFragmentsUnified(context, fragments, sb, ops); + } else { + buildSpannableFromFragmentsDuplicated(context, fragments, sb, ops); + } + } + + private static void buildSpannableFromFragmentsDuplicated( Context context, ReadableArray fragments, SpannableStringBuilder sb, @@ -152,8 +165,8 @@ private static void buildSpannableFromFragment( } ops.add( new SetSpanOperation(start, end, new ReactAbsoluteSizeSpan(textAttributes.mFontSize))); - if (textAttributes.mFontStyle != UNSET - || textAttributes.mFontWeight != UNSET + if (textAttributes.mFontStyle != IntConstants.UNSET + || textAttributes.mFontWeight != IntConstants.UNSET || textAttributes.mFontFamily != null) { ops.add( new SetSpanOperation( @@ -197,6 +210,17 @@ private static void buildSpannableFromFragment( } } + private static void buildSpannableFromFragmentsUnified( + Context context, + ReadableArray fragments, + SpannableStringBuilder sb, + List ops) { + + final var textFragmentList = new BridgeTextFragmentList(fragments); + + TextLayoutUtils.buildSpannableFromTextFragmentList(context, textFragmentList, sb, ops); + } + // public because both ReactTextViewManager and ReactTextInputManager need to use this public static Spannable getOrCreateSpannableForText( Context context, @@ -219,7 +243,7 @@ private static Spannable createSpannableFromAttributedString( // a new spannable will be wiped out List ops = new ArrayList<>(); - buildSpannableFromFragment(context, attributedString.getArray("fragments"), sb, ops); + buildSpannableFromFragments(context, attributedString.getArray("fragments"), sb, ops); // TODO T31905686: add support for inline Images // While setting the Spans on the final text, we also check whether any of them are images. @@ -368,10 +392,10 @@ public static long measureText( int maximumNumberOfLines = paragraphAttributes.hasKey(MAXIMUM_NUMBER_OF_LINES_KEY) ? paragraphAttributes.getInt(MAXIMUM_NUMBER_OF_LINES_KEY) - : UNSET; + : IntConstants.UNSET; int calculatedLineCount = - maximumNumberOfLines == UNSET || maximumNumberOfLines == 0 + maximumNumberOfLines == IntConstants.UNSET || maximumNumberOfLines == 0 ? layout.getLineCount() : Math.min(maximumNumberOfLines, layout.getLineCount()); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java index 60ef5f2e28de9d..9fb6c92c0d5954 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java @@ -8,7 +8,6 @@ package com.facebook.react.views.text; import static com.facebook.react.config.ReactFeatureFlags.enableTextSpannableCache; -import static com.facebook.react.views.text.TextAttributeProps.UNSET; import android.content.Context; import android.graphics.Color; @@ -29,12 +28,15 @@ import com.facebook.react.bridge.ReactNoCrashSoftException; import com.facebook.react.bridge.ReactSoftExceptionLogger; import com.facebook.react.bridge.WritableArray; +import com.facebook.react.common.IntConstants; import com.facebook.react.common.build.ReactBuildConfig; import com.facebook.react.common.mapbuffer.MapBuffer; import com.facebook.react.common.mapbuffer.ReadableMapBuffer; +import com.facebook.react.config.ReactFeatureFlags; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole; import com.facebook.react.uimanager.ReactAccessibilityDelegate.Role; +import com.facebook.react.views.text.fragments.MapBufferTextFragmentList; import com.facebook.yoga.YogaConstants; import com.facebook.yoga.YogaMeasureMode; import com.facebook.yoga.YogaMeasureOutput; @@ -123,7 +125,16 @@ public static boolean isRTL(MapBuffer attributedString) { == LayoutDirection.RTL; } - private static void buildSpannableFromFragment( + private static void buildSpannableFromFragments( + Context context, MapBuffer fragments, SpannableStringBuilder sb, List ops) { + if (ReactFeatureFlags.enableSpannableBuildingUnification) { + buildSpannableFromFragmentsUnified(context, fragments, sb, ops); + } else { + buildSpannableFromFragmentsDuplicated(context, fragments, sb, ops); + } + } + + private static void buildSpannableFromFragmentsDuplicated( Context context, MapBuffer fragments, SpannableStringBuilder sb, List ops) { for (int i = 0, length = fragments.getCount(); i < length; i++) { @@ -172,8 +183,8 @@ private static void buildSpannableFromFragment( } ops.add( new SetSpanOperation(start, end, new ReactAbsoluteSizeSpan(textAttributes.mFontSize))); - if (textAttributes.mFontStyle != UNSET - || textAttributes.mFontWeight != UNSET + if (textAttributes.mFontStyle != IntConstants.UNSET + || textAttributes.mFontWeight != IntConstants.UNSET || textAttributes.mFontFamily != null) { ops.add( new SetSpanOperation( @@ -217,6 +228,14 @@ private static void buildSpannableFromFragment( } } + private static void buildSpannableFromFragmentsUnified( + Context context, MapBuffer fragments, SpannableStringBuilder sb, List ops) { + + final var textFragmentList = new MapBufferTextFragmentList(fragments); + + TextLayoutUtils.buildSpannableFromTextFragmentList(context, textFragmentList, sb, ops); + } + // public because both ReactTextViewManager and ReactTextInputManager need to use this public static Spannable getOrCreateSpannableForText( Context context, @@ -260,7 +279,7 @@ private static Spannable createSpannableFromAttributedString( // a new spannable will be wiped out List ops = new ArrayList<>(); - buildSpannableFromFragment(context, attributedString.getMapBuffer(AS_KEY_FRAGMENTS), sb, ops); + buildSpannableFromFragments(context, attributedString.getMapBuffer(AS_KEY_FRAGMENTS), sb, ops); // TODO T31905686: add support for inline Images // While setting the Spans on the final text, we also check whether any of them are images. @@ -390,10 +409,10 @@ public static long measureText( int maximumNumberOfLines = paragraphAttributes.contains(PA_KEY_MAX_NUMBER_OF_LINES) ? paragraphAttributes.getInt(PA_KEY_MAX_NUMBER_OF_LINES) - : UNSET; + : IntConstants.UNSET; int calculatedLineCount = - maximumNumberOfLines == UNSET || maximumNumberOfLines == 0 + maximumNumberOfLines == IntConstants.UNSET || maximumNumberOfLines == 0 ? layout.getLineCount() : Math.min(maximumNumberOfLines, layout.getLineCount()); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutUtils.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutUtils.kt new file mode 100644 index 00000000000000..c32a3fdaacbe1f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutUtils.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text + +import android.content.Context +import android.graphics.Color +import android.text.* +import android.view.View +import com.facebook.react.common.IntConstants +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.ReactAccessibilityDelegate +import com.facebook.react.views.text.fragments.TextFragmentList + +/** + * Utility methods for building [Spannable]s + */ +internal object TextLayoutUtils { + private const val INLINE_VIEW_PLACEHOLDER = "0" + + @JvmStatic + fun buildSpannableFromTextFragmentList( + context: Context, + textFragmentList: TextFragmentList, + sb: SpannableStringBuilder, + ops: MutableList, + ) { + + for (i in 0 until textFragmentList.count) { + val fragment = textFragmentList.getFragment(i) + val start = sb.length + + // ReactRawText + val textAttributes = fragment.textAttributeProps + + addText(sb, fragment.string, textAttributes) + + val end = sb.length + val reactTag = if (fragment.hasReactTag()) fragment.reactTag else View.NO_ID + if (fragment.hasIsAttachment() && fragment.isAttachment) { + val width = PixelUtil.toPixelFromSP(fragment.width) + val height = PixelUtil.toPixelFromSP(fragment.height) + + addInlineViewPlaceholderSpan(ops, sb, reactTag, width, height) + } else if (end >= start) { + addApplicableTextAttributeSpans(ops, textAttributes, reactTag, context, start, end) + } + } + } + + @JvmStatic + fun addText( + sb: SpannableStringBuilder, text: String?, textAttributeProvider: EffectiveTextAttributeProvider + ) { + sb.append(TextTransform.apply(text, textAttributeProvider.textTransform)) + } + + @JvmStatic + fun addInlineViewPlaceholderSpan( + ops: MutableList, + sb: SpannableStringBuilder, + reactTag: Int, + width: Float, + height: Float + ) { + ops.add( + SetSpanOperation( + sb.length - INLINE_VIEW_PLACEHOLDER.length, + sb.length, + TextInlineViewPlaceholderSpan(reactTag, width.toInt(), height.toInt()) + ) + ) + } + + @JvmStatic + fun addApplicableTextAttributeSpans( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + reactTag: Int, + context: Context, + start: Int, + end: Int + ) { + addColorSpanIfApplicable(ops, textAttributeProvider, start, end) + + addBackgroundColorSpanIfApplicable(ops, textAttributeProvider, start, end) + + addLinkSpanIfApplicable(ops, textAttributeProvider, reactTag, start, end) + + addLetterSpacingSpanIfApplicable(ops, textAttributeProvider, start, end) + + addFontSizeSpanIfApplicable(ops, textAttributeProvider, start, end) + + addCustomStyleSpanIfApplicable(ops, textAttributeProvider, context, start, end) + + addUnderlineSpanIfApplicable(ops, textAttributeProvider, start, end) + + addStrikethroughSpanIfApplicable(ops, textAttributeProvider, start, end) + + addShadowStyleSpanIfApplicable(ops, textAttributeProvider, start, end) + + addLineHeightSpanIfApplicable(ops, textAttributeProvider, start, end) + + addReactTagSpan(ops, start, end, reactTag) + } + + @JvmStatic + private fun addLinkSpanIfApplicable( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + reactTag: Int, + start: Int, + end: Int + ) { + val roleIsLink = + textAttributeProvider.role?.let { it == ReactAccessibilityDelegate.Role.LINK } + ?: (textAttributeProvider.accessibilityRole == ReactAccessibilityDelegate.AccessibilityRole.LINK) + if (roleIsLink) { + ops.add(SetSpanOperation(start, end, ReactClickableSpan(reactTag))) + } + } + + @JvmStatic + private fun addColorSpanIfApplicable( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + start: Int, + end: Int + ) { + if (textAttributeProvider.isColorSet) { + ops.add( + SetSpanOperation( + start, end, ReactForegroundColorSpan(textAttributeProvider.color) + ) + ) + } + } + + @JvmStatic + private fun addBackgroundColorSpanIfApplicable( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + start: Int, + end: Int + ) { + if (textAttributeProvider.isBackgroundColorSet) { + ops.add( + SetSpanOperation( + start, end, ReactBackgroundColorSpan(textAttributeProvider.backgroundColor) + ) + ) + } + } + + @JvmStatic + private fun addLetterSpacingSpanIfApplicable( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + start: Int, + end: Int + ) { + val effectiveLetterSpacing = textAttributeProvider.effectiveLetterSpacing + + if (!effectiveLetterSpacing.isNaN()) { + ops.add(SetSpanOperation(start, end, CustomLetterSpacingSpan(effectiveLetterSpacing))) + } + } + + @JvmStatic + private fun addFontSizeSpanIfApplicable( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + start: Int, + end: Int + ) { + val effectiveFontSize = textAttributeProvider.effectiveFontSize + + if (effectiveFontSize != IntConstants.UNSET) { + ops.add(SetSpanOperation(start, end, ReactAbsoluteSizeSpan(effectiveFontSize))) + } + } + + + @JvmStatic + private fun addCustomStyleSpanIfApplicable( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + context: Context, + start: Int, + end: Int + ) { + val fontStyle = textAttributeProvider.fontStyle + val fontWeight = textAttributeProvider.fontWeight + val fontFamily = textAttributeProvider.fontFamily + + if (fontStyle != IntConstants.UNSET || fontWeight != IntConstants.UNSET || fontFamily != null) { + ops.add( + SetSpanOperation( + start, end, CustomStyleSpan( + fontStyle, + fontWeight, + textAttributeProvider.fontFeatureSettings, + fontFamily, + context.assets + ) + ) + ) + } + } + + @JvmStatic + private fun addUnderlineSpanIfApplicable( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + start: Int, + end: Int + ) { + if (textAttributeProvider.isUnderlineTextDecorationSet) { + ops.add(SetSpanOperation(start, end, ReactUnderlineSpan())) + } + } + + @JvmStatic + private fun addStrikethroughSpanIfApplicable( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + start: Int, + end: Int + ) { + if (textAttributeProvider.isLineThroughTextDecorationSet) { + ops.add(SetSpanOperation(start, end, ReactStrikethroughSpan())) + } + } + + @JvmStatic + private fun addShadowStyleSpanIfApplicable( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + start: Int, + end: Int + ) { + val hasTextShadowOffset = + textAttributeProvider.textShadowOffsetDx != 0f || textAttributeProvider.textShadowOffsetDy != 0f + val hasTextShadowRadius = textAttributeProvider.textShadowRadius != 0f + val hasTextShadowColorAlpha = Color.alpha(textAttributeProvider.textShadowColor) != 0 + + if ((hasTextShadowOffset || hasTextShadowRadius) && hasTextShadowColorAlpha) { + ops.add( + SetSpanOperation( + start, end, ShadowStyleSpan( + textAttributeProvider.textShadowOffsetDx, + textAttributeProvider.textShadowOffsetDy, + textAttributeProvider.textShadowRadius, + textAttributeProvider.textShadowColor + ) + ) + ) + } + } + + + @JvmStatic + private fun addLineHeightSpanIfApplicable( + ops: MutableList, + textAttributeProvider: EffectiveTextAttributeProvider, + start: Int, + end: Int + ) { + val effectiveLineHeight = textAttributeProvider.effectiveLineHeight + if (!effectiveLineHeight.isNaN()) { + ops.add(SetSpanOperation(start, end, CustomLineHeightSpan(effectiveLineHeight))) + } + } + + + @JvmStatic + private fun addReactTagSpan( + ops: MutableList, start: Int, end: Int, reactTag: Int + ) { + ops.add(SetSpanOperation(start, end, ReactTagSpan(reactTag))) + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextTransform.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextTransform.java index ad96b7d4437b76..e4f1f37bfd9740 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextTransform.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextTransform.java @@ -8,6 +8,7 @@ package com.facebook.react.views.text; import java.text.BreakIterator; +import androidx.annotation.Nullable; /** Types of text transforms for CustomTextTransformSpan */ public enum TextTransform { @@ -17,7 +18,7 @@ public enum TextTransform { CAPITALIZE, UNSET; - public static String apply(String text, TextTransform textTransform) { + public static String apply(@Nullable String text, TextTransform textTransform) { if (text == null) { return null; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/BridgeTextFragment.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/BridgeTextFragment.kt new file mode 100644 index 00000000000000..fd09104d8f1f0c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/BridgeTextFragment.kt @@ -0,0 +1,33 @@ +package com.facebook.react.views.text.fragments + +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.ViewProps +import com.facebook.react.views.text.TextAttributeProps + +/** + * A [TextFragment] implementation backed by a a [ReadableMap] + */ +internal class BridgeTextFragment(private val fragment: ReadableMap) : TextFragment { + override val textAttributeProps: TextAttributeProps + get() = TextAttributeProps.fromReadableMap(ReactStylesDiffMap(fragment.getMap("textAttributes"))) + + override val string: String? + get() = fragment.getString("string") + + override fun hasReactTag(): Boolean = fragment.hasKey("reactTag") + + override val reactTag: Int + get() = fragment.getInt("reactTag") + + override fun hasIsAttachment(): Boolean = fragment.hasKey(ViewProps.IS_ATTACHMENT) + + override val isAttachment: Boolean + get() = fragment.getBoolean(ViewProps.IS_ATTACHMENT) + + override val width: Double + get() = fragment.getDouble(ViewProps.WIDTH) + + override val height: Double + get() = fragment.getDouble(ViewProps.HEIGHT) +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/BridgeTextFragmentList.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/BridgeTextFragmentList.kt new file mode 100644 index 00000000000000..b57622fc72a6f4 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/BridgeTextFragmentList.kt @@ -0,0 +1,13 @@ +package com.facebook.react.views.text.fragments + +import com.facebook.react.bridge.ReadableArray + +/** + * A list of [TextFragment]s backed by a [ReadableArray] + */ +internal class BridgeTextFragmentList(private val fragments: ReadableArray) : TextFragmentList { + override fun getFragment(index: Int): TextFragment = BridgeTextFragment(fragments.getMap(index)) + + override val count: Int + get() = fragments.size() +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/MapBufferTextFragment.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/MapBufferTextFragment.kt new file mode 100644 index 00000000000000..6e11e7a996fb4e --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/MapBufferTextFragment.kt @@ -0,0 +1,38 @@ +package com.facebook.react.views.text.fragments + +import com.facebook.react.common.mapbuffer.MapBuffer +import com.facebook.react.views.text.TextAttributeProps + +import com.facebook.react.views.text.TextLayoutManagerMapBuffer.FR_KEY_HEIGHT +import com.facebook.react.views.text.TextLayoutManagerMapBuffer.FR_KEY_IS_ATTACHMENT +import com.facebook.react.views.text.TextLayoutManagerMapBuffer.FR_KEY_REACT_TAG +import com.facebook.react.views.text.TextLayoutManagerMapBuffer.FR_KEY_STRING +import com.facebook.react.views.text.TextLayoutManagerMapBuffer.FR_KEY_TEXT_ATTRIBUTES +import com.facebook.react.views.text.TextLayoutManagerMapBuffer.FR_KEY_WIDTH + +/** + * A [TextFragment] implementation backed by a [MapBuffer] + */ +internal class MapBufferTextFragment(private val fragment: MapBuffer) : TextFragment { + override val textAttributeProps: TextAttributeProps + get() = TextAttributeProps.fromMapBuffer(fragment.getMapBuffer(FR_KEY_TEXT_ATTRIBUTES.toInt())) + + override val string: String + get() = fragment.getString(FR_KEY_STRING.toInt()) + + override fun hasReactTag(): Boolean = fragment.contains(FR_KEY_REACT_TAG.toInt()) + + override val reactTag: Int + get() = fragment.getInt(FR_KEY_REACT_TAG.toInt()) + + override fun hasIsAttachment(): Boolean = fragment.contains(FR_KEY_IS_ATTACHMENT.toInt()) + + override val isAttachment: Boolean + get() = fragment.getBoolean(FR_KEY_IS_ATTACHMENT.toInt()) + + override val width: Double + get() = fragment.getDouble(FR_KEY_WIDTH.toInt()) + + override val height: Double + get() = fragment.getDouble(FR_KEY_HEIGHT.toInt()) +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/MapBufferTextFragmentList.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/MapBufferTextFragmentList.kt new file mode 100644 index 00000000000000..75f76cefc41a78 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/MapBufferTextFragmentList.kt @@ -0,0 +1,14 @@ +package com.facebook.react.views.text.fragments + +import com.facebook.react.common.mapbuffer.MapBuffer + +/** + * A list of [TextFragment]s backed by a [MapBuffer] + */ +internal class MapBufferTextFragmentList(private val fragments: MapBuffer) : TextFragmentList { + override fun getFragment(index: Int): TextFragment = + MapBufferTextFragment(fragments.getMapBuffer(index)) + + override val count: Int + get() = fragments.count +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/TextFragment.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/TextFragment.kt new file mode 100644 index 00000000000000..7d86c345fe06f3 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/TextFragment.kt @@ -0,0 +1,24 @@ +package com.facebook.react.views.text.fragments + +import com.facebook.react.views.text.TextAttributeProps + +/** + * Interface for a text fragment + */ +internal interface TextFragment { + val textAttributeProps: TextAttributeProps + + val string: String? + + fun hasReactTag(): Boolean + + val reactTag: Int + + fun hasIsAttachment(): Boolean + + val isAttachment: Boolean + + val width: Double + + val height: Double +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/TextFragmentList.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/TextFragmentList.kt new file mode 100644 index 00000000000000..b50ed43cb646e3 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/fragments/TextFragmentList.kt @@ -0,0 +1,10 @@ +package com.facebook.react.views.text.fragments + +/** + * Interface for a list of [TextFragment]s + */ +internal interface TextFragmentList { + fun getFragment(index: Int): TextFragment + + val count: Int +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index 1c7950e4b3c006..014bd48093cc20 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java @@ -46,6 +46,7 @@ import com.facebook.infer.annotation.Assertions; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReactSoftExceptionLogger; +import com.facebook.react.common.IntConstants; import com.facebook.react.common.build.ReactBuildConfig; import com.facebook.react.uimanager.ReactAccessibilityDelegate; import com.facebook.react.uimanager.StateWrapper; @@ -96,8 +97,6 @@ public class ReactEditText extends AppCompatEditText { /** A count of events sent to JS or C++. */ protected int mNativeEventCount; - private static final int UNSET = -1; - private @Nullable ArrayList mListeners; private @Nullable TextWatcherDelegator mTextWatcherDelegator; private int mStagedInputType; @@ -114,8 +113,8 @@ public class ReactEditText extends AppCompatEditText { private TextAttributes mTextAttributes; private boolean mTypefaceDirty = false; private @Nullable String mFontFamily = null; - private int mFontWeight = UNSET; - private int mFontStyle = UNSET; + private int mFontWeight = IntConstants.UNSET; + private int mFontStyle = IntConstants.UNSET; private boolean mAutoFocus = false; private boolean mDidAttachToWindow = false; private @Nullable String mPlaceholder = null; @@ -379,7 +378,7 @@ public void maybeSetSelection(int eventCounter, int start, int end) { return; } - if (start != UNSET && end != UNSET) { + if (start != IntConstants.UNSET && end != IntConstants.UNSET) { // clamp selection values for safety start = clampToTextLength(start); end = clampToTextLength(end); @@ -588,8 +587,8 @@ public void maybeUpdateTypeface() { // Match behavior of CustomStyleSpan and enable SUBPIXEL_TEXT_FLAG when setting anything // nonstandard - if (mFontStyle != UNSET - || mFontWeight != UNSET + if (mFontStyle != IntConstants.UNSET + || mFontWeight != IntConstants.UNSET || mFontFamily != null || getFontFeatureSettings() != null) { setPaintFlags(getPaintFlags() | Paint.SUBPIXEL_TEXT_FLAG); @@ -819,8 +818,8 @@ private void addSpansFromStyleAttributes(SpannableStringBuilder workingText) { new CustomLetterSpacingSpan(effectiveLetterSpacing), 0, workingText.length(), spanFlags); } - if (mFontStyle != UNSET - || mFontWeight != UNSET + if (mFontStyle != IntConstants.UNSET + || mFontWeight != IntConstants.UNSET || mFontFamily != null || getFontFeatureSettings() != null) { workingText.setSpan( diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java index 878da5199174ca..0b904002445559 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java @@ -17,6 +17,7 @@ import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; import com.facebook.react.R; +import com.facebook.react.common.IntConstants; import com.facebook.react.common.ReactConstants; import com.facebook.react.common.annotations.VisibleForTesting; import com.facebook.react.uimanager.Spacing; @@ -36,7 +37,7 @@ public class ReactTextInputShadowNode extends ReactBaseTextShadowNode implements YogaMeasureFunction { - private int mMostRecentEventCount = UNSET; + private int mMostRecentEventCount = IntConstants.UNSET; private @Nullable EditText mInternalEditText; private @Nullable ReactTextInputLocalData mLocalData; @@ -108,7 +109,7 @@ public long measure( } else { editText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextAttributes.getEffectiveFontSize()); - if (mNumberOfLines != UNSET) { + if (mNumberOfLines != IntConstants.UNSET) { editText.setLines(mNumberOfLines); } @@ -191,7 +192,7 @@ public void setTextBreakStrategy(@Nullable String textBreakStrategy) { public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) { super.onCollectExtraUpdates(uiViewOperationQueue); - if (mMostRecentEventCount != UNSET) { + if (mMostRecentEventCount != IntConstants.UNSET) { ReactTextUpdate reactTextUpdate = new ReactTextUpdate( spannedFromShadowNode(