diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.java index 10bda1b0d6b9f6..ad195d14d85b5f 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.java @@ -31,6 +31,13 @@ public static float toPixelFromDIP(double value) { return toPixelFromDIP((float) value); } + /** + * Convert from PX to SP + */ + public static float toSPFromPixel(float value) { + return value / DisplayMetricsHolder.getScreenDisplayMetrics().scaledDensity; + } + /** * Convert from SP to PX */ diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewManager.java index 110401e92e34dd..c7936b6ca29c75 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewManager.java @@ -10,7 +10,9 @@ import android.view.View; import com.facebook.react.bridge.BaseJavaModule; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableNativeMap; import com.facebook.react.touch.JSResponderHandler; import com.facebook.react.touch.ReactInterceptingViewGroup; import com.facebook.react.uimanager.annotations.ReactProp; @@ -202,4 +204,16 @@ public void receiveCommand(T root, int commandId, @Nullable ReadableArray args) public Map getNativeProps() { return ViewManagerPropertyUpdater.getNativeProps(getClass(), getShadowNodeClass()); } + + public float[] measure( + ReactContext context, + T view, + ReadableNativeMap localData, + ReadableNativeMap props, + float width, + int widthMode, + float height, + int heightMode) { + return null; + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index f5dd204cfc9ff6..4c7023f07ea490 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -11,6 +11,7 @@ import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Layout; +import android.text.Spannable; import android.text.Spanned; import android.text.TextUtils; import android.view.Gravity; @@ -36,6 +37,7 @@ public class ReactTextView extends TextView implements ReactCompoundView { private TextUtils.TruncateAt mEllipsizeLocation = TextUtils.TruncateAt.END; private ReactViewBackgroundManager mReactBackgroundManager; + private Spannable mSpanned; public ReactTextView(Context context) { super(context); @@ -255,4 +257,12 @@ public void setBorderRadius(float borderRadius, int position) { public void setBorderStyle(@Nullable String style) { mReactBackgroundManager.setBorderStyle(style); } + + public void setSpanned(Spannable spanned) { + mSpanned = spanned; + } + + public Spannable getSpanned() { + return mSpanned; + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java index 7f63f2b20ac6ac..b2f813ba5f5098 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java @@ -9,11 +9,14 @@ import android.text.Spannable; import com.facebook.react.common.MapBuilder; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReadableNativeMap; import com.facebook.react.common.annotations.VisibleForTesting; import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.uimanager.ThemedReactContext; import java.util.Map; import javax.annotation.Nullable; +import com.facebook.yoga.YogaMeasureMode; /** * Concrete class for {@link ReactTextAnchorViewManager} which represents view managers of anchor @@ -66,4 +69,25 @@ protected void onAfterUpdateTransaction(ReactTextView view) { public @Nullable Map getExportedCustomDirectEventTypeConstants() { return MapBuilder.of("topTextLayout", MapBuilder.of("registrationName", "onTextLayout")); } + + public float[] measure( + ReactContext context, + ReactTextView view, + ReadableNativeMap localData, + ReadableNativeMap props, + float width, + int widthMode, + float height, + int heightMode) { + + // TODO: should widthMode and heightMode be a YogaMeasureMode? + return TextLayoutManager.measureText(context, + view, + localData, + props, + width, + YogaMeasureMode.fromInt(widthMode), + height, + YogaMeasureMode.fromInt(heightMode)); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java new file mode 100644 index 00000000000000..c69faac2e4cab8 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java @@ -0,0 +1,416 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * + * 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.graphics.Typeface; +import android.os.Build; +import android.text.Layout; +import android.view.Gravity; +import com.facebook.react.bridge.JSApplicationIllegalArgumentException; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.uimanager.PixelUtil; +import com.facebook.react.uimanager.ReactStylesDiffMap; +import com.facebook.react.uimanager.ViewProps; +import com.facebook.yoga.YogaDirection; +import javax.annotation.Nullable; + +public class TextAttributeProps { + + private static final String INLINE_IMAGE_PLACEHOLDER = "I"; + 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"; + private static final String PROP_SHADOW_RADIUS = "textShadowRadius"; + private static final String PROP_SHADOW_COLOR = "textShadowColor"; + + private static final String PROP_TEXT_TRANSFORM = "textTransform"; + + private static final int DEFAULT_TEXT_SHADOW_COLOR = 0x55000000; + + protected float mLineHeight = Float.NaN; + protected float mLetterSpacing = Float.NaN; + protected boolean mIsColorSet = false; + protected boolean mAllowFontScaling = true; + protected int mColor; + protected boolean mIsBackgroundColorSet = false; + protected int mBackgroundColor; + + protected int mNumberOfLines = UNSET; + protected int mFontSize = UNSET; + protected float mFontSizeInput = UNSET; + protected float mLineHeightInput = UNSET; + protected float mLetterSpacingInput = Float.NaN; + protected int mTextAlign = Gravity.NO_GRAVITY; + protected int mTextBreakStrategy = + (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) ? 0 : Layout.BREAK_STRATEGY_HIGH_QUALITY; + protected TextTransform mTextTransform = TextTransform.UNSET; + + protected float mTextShadowOffsetDx = 0; + protected float mTextShadowOffsetDy = 0; + protected float mTextShadowRadius = 1; + protected int mTextShadowColor = DEFAULT_TEXT_SHADOW_COLOR; + + protected boolean mIsUnderlineTextDecorationSet = false; + protected boolean mIsLineThroughTextDecorationSet = false; + protected boolean mIncludeFontPadding = true; + + /** + * 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 mFontWeight = 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 + * nodes. To retain that style, you have to add it to those nodes explicitly. + * Example, Android 4.4: + * Bold Text + * Bold Text + * Bold Text + * + * Not Bold Text + * Not Bold Text + * Not Bold Text + * + * Not Bold Text + * Bold Text + * Bold Text + */ + protected @Nullable String mFontFamily = null; + + protected boolean mContainsImages = false; + protected float mHeightOfTallestInlineImage = Float.NaN; + + private final ReactStylesDiffMap mProps; + + + public TextAttributeProps(ReactStylesDiffMap props) { + mProps = props; + setNumberOfLines(getIntProp(ViewProps.NUMBER_OF_LINES, UNSET)); + setLineHeight(getFloatProp(ViewProps.LINE_HEIGHT, UNSET)); + setLetterSpacing(getFloatProp(ViewProps.LETTER_SPACING, Float.NaN)); + setAllowFontScaling(getBooleanProp(ViewProps.ALLOW_FONT_SCALING, true)); + setTextAlign(getStringProp(ViewProps.TEXT_ALIGN)); + setFontSize(getFloatProp(ViewProps.FONT_SIZE, UNSET)); + setColor(props.hasKey(ViewProps.COLOR) ? props.getInt(ViewProps.COLOR, 0) : null); + setColor(props.hasKey("foregroundColor") ? props.getInt("foregroundColor", 0) : null); + setBackgroundColor(props.hasKey(ViewProps.BACKGROUND_COLOR) ? props.getInt(ViewProps.BACKGROUND_COLOR, 0) : null); + setFontFamily(getStringProp(ViewProps.FONT_FAMILY)); + setFontWeight(getStringProp(ViewProps.FONT_WEIGHT)); + setFontStyle(getStringProp(ViewProps.FONT_STYLE)); + setIncludeFontPadding(getBooleanProp(ViewProps.INCLUDE_FONT_PADDING, true)); + setTextDecorationLine(getStringProp(ViewProps.TEXT_DECORATION_LINE)); + setTextBreakStrategy(getStringProp(ViewProps.TEXT_BREAK_STRATEGY)); + setTextShadowOffset(props.hasKey(PROP_SHADOW_OFFSET) ? props.getMap(PROP_SHADOW_OFFSET) : null); + setTextShadowRadius(getIntProp(PROP_SHADOW_RADIUS, 1)); + setTextShadowColor(getIntProp(PROP_SHADOW_COLOR, DEFAULT_TEXT_SHADOW_COLOR)); + setTextTransform(getStringProp(PROP_TEXT_TRANSFORM)); + } + + private boolean getBooleanProp(String name, boolean defaultValue) { + if (mProps.hasKey(name)) { + return mProps.getBoolean(name, defaultValue); + } else { + return defaultValue; + } + } + + private String getStringProp(String name) { + if (mProps.hasKey(name)) { + return mProps.getString(name); + } else { + return null; + } + } + + private int getIntProp(String name, int defaultvalue) { + if (mProps.hasKey(name)) { + return mProps.getInt(name, defaultvalue); + } else { + return defaultvalue; + } + } + + private float getFloatProp(String name, float defaultvalue) { + if (mProps.hasKey(name)) { + return mProps.getFloat(name, defaultvalue); + } else { + return defaultvalue; + } + } + + // Returns a line height which takes into account the requested line height + // and the height of the inline images. + public float getEffectiveLineHeight() { + boolean useInlineViewHeight = + !Float.isNaN(mLineHeight) + && !Float.isNaN(mHeightOfTallestInlineImage) + && mHeightOfTallestInlineImage > mLineHeight; + return useInlineViewHeight ? mHeightOfTallestInlineImage : mLineHeight; + } + + // Return text alignment according to LTR or RTL style + public int getTextAlign() { + int textAlign = mTextAlign; + if (getLayoutDirection() == YogaDirection.RTL) { + if (textAlign == Gravity.RIGHT) { + textAlign = Gravity.LEFT; + } else if (textAlign == Gravity.LEFT) { + textAlign = Gravity.RIGHT; + } + } + return textAlign; + } + + public void setNumberOfLines(int numberOfLines) { + mNumberOfLines = numberOfLines == 0 ? UNSET : numberOfLines; + } + + public void setLineHeight(float lineHeight) { + mLineHeightInput = lineHeight; + if (lineHeight == UNSET) { + mLineHeight = Float.NaN; + } else { + mLineHeight = + mAllowFontScaling + ? PixelUtil.toPixelFromSP(lineHeight) + : PixelUtil.toPixelFromDIP(lineHeight); + } + } + + public void setLetterSpacing(float letterSpacing) { + mLetterSpacingInput = letterSpacing; + mLetterSpacing = mAllowFontScaling + ? PixelUtil.toPixelFromSP(mLetterSpacingInput) + : PixelUtil.toPixelFromDIP(mLetterSpacingInput); + } + + public void setAllowFontScaling(boolean allowFontScaling) { + if (allowFontScaling != mAllowFontScaling) { + mAllowFontScaling = allowFontScaling; + setFontSize(mFontSizeInput); + setLineHeight(mLineHeightInput); + setLetterSpacing(mLetterSpacingInput); + } + } + + public void setTextAlign(@Nullable String textAlign) { + if (textAlign == null || "auto".equals(textAlign)) { + mTextAlign = Gravity.NO_GRAVITY; + } else if ("left".equals(textAlign)) { + mTextAlign = Gravity.LEFT; + } else if ("right".equals(textAlign)) { + mTextAlign = Gravity.RIGHT; + } else if ("center".equals(textAlign)) { + mTextAlign = Gravity.CENTER_HORIZONTAL; + } else if ("justify".equals(textAlign)) { + // Fallback gracefully for cross-platform compat instead of error + mTextAlign = Gravity.LEFT; + } else { + throw new JSApplicationIllegalArgumentException("Invalid textAlign: " + textAlign); + } + } + + public void setFontSize(float fontSize) { + mFontSizeInput = fontSize; + if (fontSize != UNSET) { + fontSize = + mAllowFontScaling + ? (float) Math.ceil(PixelUtil.toPixelFromSP(fontSize)) + : (float) Math.ceil(PixelUtil.toPixelFromDIP(fontSize)); + } + mFontSize = (int) fontSize; + } + + public void setColor(@Nullable Integer color) { + mIsColorSet = (color != null); + if (mIsColorSet) { + mColor = color; + } + } + + public void setBackgroundColor(Integer color) { + //TODO: Don't apply background color to anchor TextView since it will be applied on the View directly + //if (!isVirtualAnchor()) { + mIsBackgroundColorSet = (color != null); + if (mIsBackgroundColorSet) { + mBackgroundColor = color; + } + //} + } + + public void setFontFamily(@Nullable String fontFamily) { + mFontFamily = fontFamily; + } + + /** + /* This code is duplicated in ReactTextInputManager + /* TODO: Factor into a common place they can both use + */ + public void setFontWeight(@Nullable String fontWeightString) { + int fontWeightNumeric = + fontWeightString != null ? parseNumericFontWeight(fontWeightString) : -1; + int fontWeight = UNSET; + if (fontWeightNumeric >= 500 || "bold".equals(fontWeightString)) { + fontWeight = Typeface.BOLD; + } else if ("normal".equals(fontWeightString) + || (fontWeightNumeric != -1 && fontWeightNumeric < 500)) { + fontWeight = Typeface.NORMAL; + } + if (fontWeight != mFontWeight) { + mFontWeight = fontWeight; + } + } + + /** + /* This code is duplicated in ReactTextInputManager + /* TODO: Factor into a common place they can both use + */ + public void setFontStyle(@Nullable String fontStyleString) { + int fontStyle = UNSET; + if ("italic".equals(fontStyleString)) { + fontStyle = Typeface.ITALIC; + } else if ("normal".equals(fontStyleString)) { + fontStyle = Typeface.NORMAL; + } + if (fontStyle != mFontStyle) { + mFontStyle = fontStyle; + } + } + + public void setIncludeFontPadding(boolean includepad) { + mIncludeFontPadding = includepad; + } + + public void setTextDecorationLine(@Nullable String textDecorationLineString) { + mIsUnderlineTextDecorationSet = false; + mIsLineThroughTextDecorationSet = false; + if (textDecorationLineString != null) { + for (String textDecorationLineSubString : textDecorationLineString.split(" ")) { + if ("underline".equals(textDecorationLineSubString)) { + mIsUnderlineTextDecorationSet = true; + } else if ("line-through".equals(textDecorationLineSubString)) { + mIsLineThroughTextDecorationSet = true; + } + } + } + } + + public void setTextBreakStrategy(@Nullable String textBreakStrategy) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return; + } + + if (textBreakStrategy == null || "highQuality".equals(textBreakStrategy)) { + mTextBreakStrategy = Layout.BREAK_STRATEGY_HIGH_QUALITY; + } else if ("simple".equals(textBreakStrategy)) { + mTextBreakStrategy = Layout.BREAK_STRATEGY_SIMPLE; + } else if ("balanced".equals(textBreakStrategy)) { + mTextBreakStrategy = Layout.BREAK_STRATEGY_BALANCED; + } else { + throw new JSApplicationIllegalArgumentException( + "Invalid textBreakStrategy: " + textBreakStrategy); + } + } + + public void setTextShadowOffset(ReadableMap offsetMap) { + mTextShadowOffsetDx = 0; + mTextShadowOffsetDy = 0; + + if (offsetMap != null) { + if (offsetMap.hasKey(PROP_SHADOW_OFFSET_WIDTH) + && !offsetMap.isNull(PROP_SHADOW_OFFSET_WIDTH)) { + mTextShadowOffsetDx = + PixelUtil.toPixelFromDIP(offsetMap.getDouble(PROP_SHADOW_OFFSET_WIDTH)); + } + if (offsetMap.hasKey(PROP_SHADOW_OFFSET_HEIGHT) + && !offsetMap.isNull(PROP_SHADOW_OFFSET_HEIGHT)) { + mTextShadowOffsetDy = + PixelUtil.toPixelFromDIP(offsetMap.getDouble(PROP_SHADOW_OFFSET_HEIGHT)); + } + } + } + + public void setTextShadowRadius(float textShadowRadius) { + if (textShadowRadius != mTextShadowRadius) { + mTextShadowRadius = textShadowRadius; + } + } + + public void setTextShadowColor(int textShadowColor) { + if (textShadowColor != mTextShadowColor) { + mTextShadowColor = textShadowColor; + + } + } + + public void setTextTransform(@Nullable String textTransform) { + if (textTransform == null || "none".equals(textTransform)) { + mTextTransform = TextTransform.NONE; + } else if ("uppercase".equals(textTransform)) { + mTextTransform = TextTransform.UPPERCASE; + } else if ("lowercase".equals(textTransform)) { + mTextTransform = TextTransform.LOWERCASE; + } else if ("capitalize".equals(textTransform)) { + mTextTransform = TextTransform.CAPITALIZE; + } else { + throw new JSApplicationIllegalArgumentException("Invalid textTransform: " + textTransform); + } + } + + /** + * Return -1 if the input string is not a valid numeric fontWeight (100, 200, ..., 900), otherwise + * return the weight. + * + * This code is duplicated in ReactTextInputManager + * TODO: Factor into a common place they can both use + */ + private static int parseNumericFontWeight(String fontWeightString) { + // This should be much faster than using regex to verify input and Integer.parseInt + return fontWeightString.length() == 3 + && fontWeightString.endsWith("00") + && fontWeightString.charAt(0) <= '9' + && fontWeightString.charAt(0) >= '1' + ? 100 * (fontWeightString.charAt(0) - '0') + : -1; + } + + //TODO remove this from here + private YogaDirection getLayoutDirection() { + return YogaDirection.LTR; + } + + public float getBottomPadding() { + // TODO convert into constants + return getFloatProp("bottomPadding", 0f); + } + + public float getLeftPadding() { + return getFloatProp("leftPadding", 0f); + } + + public float getStartPadding() { + return getFloatProp("startPadding", 0f); + } + + public float getEndPadding() { + return getFloatProp("endPadding", 0f); + } + + public float getTopPadding() { + return getFloatProp("topPadding", 0f); + } + + public float getRightPadding() { + return getFloatProp("rightPadding", 0f); + } +} 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 new file mode 100644 index 00000000000000..12b4fc06521b01 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java @@ -0,0 +1,337 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * + * 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 static com.facebook.react.views.text.TextAttributeProps.UNSET; + +import android.content.Context; +import android.os.Build; +import android.text.BoringLayout; +import android.text.Layout; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.StaticLayout; +import android.text.TextPaint; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.UnderlineSpan; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableNativeMap; +import com.facebook.react.uimanager.PixelUtil; +import com.facebook.react.uimanager.ReactStylesDiffMap; +import com.facebook.yoga.YogaConstants; +import com.facebook.yoga.YogaMeasureMode; +import java.util.ArrayList; +import java.util.List; + +/** + * Class responsible of creating {@link Spanned} object for the JS representation of Text + */ +public class TextLayoutManager { + + // It's important to pass the ANTI_ALIAS_FLAG flag to the constructor rather than setting it + // later by calling setFlags. This is because the latter approach triggers a bug on Android 4.4.2. + // The bug is that unicode emoticons aren't measured properly which causes text to be clipped. + private static final TextPaint sTextPaintInstance = new TextPaint(TextPaint.ANTI_ALIAS_FLAG); + + private static void buildSpannedFromShadowNode( + Context context, + ReadableArray fragments, + SpannableStringBuilder sb, + List ops) { + + for (int i = 0, length = fragments.size(); i < length; i++) { + ReadableMap fragment = fragments.getMap(i); + int start = sb.length(); + + //ReactRawText + sb.append(fragment.getString("string")); + +// TODO: add support for TextInlineImage and BaseText +// if (child instanceof ReactRawTextShadowNode) { +// sb.append(((ReactRawTextShadowNode) child).getText()); +// } else if (child instanceof ReactBaseTextShadowNode) { +// buildSpannedFromShadowNode((ReactBaseTextShadowNode) child, sb, ops); +// } else if (child instanceof ReactTextInlineImageShadowNode) { +// // 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_IMAGE_PLACEHOLDER); +// ops.add( +// new SetSpanOperation( +// sb.length() - INLINE_IMAGE_PLACEHOLDER.length(), +// sb.length(), +// ((ReactTextInlineImageShadowNode) child).buildInlineImageSpan())); +// } else { +// throw new IllegalViewOperationException( +// "Unexpected view type nested under text node: " + child.getClass()); +// } + + TextAttributeProps textAttributes = new TextAttributeProps(new ReactStylesDiffMap(fragment.getMap("textAttributes"))); + int end = sb.length(); + if (end >= start) { + if (textAttributes.mIsColorSet) { + ops.add(new SetSpanOperation(start, end, new ForegroundColorSpan(textAttributes.mColor))); + } + if (textAttributes.mIsBackgroundColorSet) { + ops.add( + new SetSpanOperation( + start, end, new BackgroundColorSpan(textAttributes.mBackgroundColor))); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (!Float.isNaN(textAttributes.mLetterSpacing)) { + ops.add(new SetSpanOperation( + start, + end, + new CustomLetterSpacingSpan(textAttributes.mLetterSpacing))); + } + } + if (textAttributes.mFontSize != UNSET) { + ops.add( + new SetSpanOperation( + start, end, new AbsoluteSizeSpan((int) (textAttributes.mFontSize)))); + } + if (textAttributes.mFontStyle != UNSET + || textAttributes.mFontWeight != UNSET + || textAttributes.mFontFamily != null) { + ops.add( + new SetSpanOperation( + start, + end, + new CustomStyleSpan( + textAttributes.mFontStyle, + textAttributes.mFontWeight, + textAttributes.mFontFamily, + context.getAssets()))); + } + if (textAttributes.mIsUnderlineTextDecorationSet) { + ops.add(new SetSpanOperation(start, end, new UnderlineSpan())); + } + if (textAttributes.mIsLineThroughTextDecorationSet) { + ops.add(new SetSpanOperation(start, end, new StrikethroughSpan())); + } + if (textAttributes.mTextShadowOffsetDx != 0 || textAttributes.mTextShadowOffsetDy != 0) { + ops.add( + new SetSpanOperation( + start, + end, + new ShadowStyleSpan( + textAttributes.mTextShadowOffsetDx, + textAttributes.mTextShadowOffsetDy, + textAttributes.mTextShadowRadius, + textAttributes.mTextShadowColor))); + } + if (!Float.isNaN(textAttributes.getEffectiveLineHeight())) { + ops.add( + new SetSpanOperation( + start, end, new CustomLineHeightSpan(textAttributes.getEffectiveLineHeight()))); + } + if (textAttributes.mTextTransform != TextTransform.UNSET && textAttributes.mTextTransform != TextTransform.NONE) { + ops.add( + new SetSpanOperation( + start, + end, + new CustomTextTransformSpan(textAttributes.mTextTransform))); + } + + //TODO: add react tag as part of the fragments, react tag is used on Touch events + int reactTag = 1; + + ops.add(new SetSpanOperation(start, end, new ReactTagSpan(reactTag))); + } + } + } + + protected static Spannable spannedFromTextFragments( + Context context, + ReadableArray fragments, String text) { + SpannableStringBuilder sb = new SpannableStringBuilder(); + + // TODO(5837930): Investigate whether it's worth optimizing this part and do it if so + + // The {@link SpannableStringBuilder} implementation require setSpan operation to be called + // up-to-bottom, otherwise all the spannables that are withing the region for which one may set + // a new spannable will be wiped out + List ops = new ArrayList<>(); + + buildSpannedFromShadowNode(context, fragments, sb, ops); + + // TODO: add support for AllowScaling in C++ +// if (textShadowNode.mFontSize == UNSET) { +// int defaultFontSize = +// textShadowNode.mAllowFontScaling +// ? (int) Math.ceil(PixelUtil.toPixelFromSP(ViewDefaults.FONT_SIZE_SP)) +// : (int) Math.ceil(PixelUtil.toPixelFromDIP(ViewDefaults.FONT_SIZE_SP)); +// +// ops.add(new SetSpanOperation(0, sb.length(), new AbsoluteSizeSpan(defaultFontSize))); +// } +// +// textShadowNode.mContainsImages = false; +// textShadowNode.mHeightOfTallestInlineImage = Float.NaN; + + // While setting the Spans on the final text, we also check whether any of them are images. + int priority = 0; + for (SetSpanOperation op : ops) { +// TODO: add support for TextInlineImage in C++ +// if (op.what instanceof TextInlineImageSpan) { +// int height = ((TextInlineImageSpan) op.what).getHeight(); +// textShadowNode.mContainsImages = true; +// if (Float.isNaN(textShadowNode.mHeightOfTallestInlineImage) +// || height > textShadowNode.mHeightOfTallestInlineImage) { +// textShadowNode.mHeightOfTallestInlineImage = height; +// } +// } + + // Actual order of calling {@code execute} does NOT matter, + // but the {@code priority} DOES matter. + op.execute(sb, priority); + priority++; + } + + return sb; + } + + public static float[] measureText( + ReactContext context, + ReactTextView view, + ReadableNativeMap attributedString, + ReadableNativeMap paragraphAttributes, + float width, + YogaMeasureMode widthYogaMeasureMode, + float height, + YogaMeasureMode heightYogaMeasureMode) { + + // TODO(5578671): Handle text direction (see View#getTextDirectionHeuristic) + TextPaint textPaint = sTextPaintInstance; + Layout layout; + + Spannable preparedSpannableText = view == null ? null : view.getSpanned(); + + // TODO add these props to paragraph attributes + int textBreakStrategy = Layout.BREAK_STRATEGY_HIGH_QUALITY; + boolean includeFontPadding = true; + + if (preparedSpannableText == null) { + preparedSpannableText = spannedFromTextFragments(context, attributedString.getArray("fragments"), attributedString.getString("string")); + } + + if (preparedSpannableText == null) { + throw new IllegalStateException("Spannable element has not been prepared in onBeforeLayout"); + } + Spanned text = preparedSpannableText; + BoringLayout.Metrics boring = BoringLayout.isBoring(text, textPaint); + float desiredWidth = boring == null ? + Layout.getDesiredWidth(text, textPaint) : Float.NaN; + + // technically, width should never be negative, but there is currently a bug in + boolean unconstrainedWidth = widthYogaMeasureMode == YogaMeasureMode.UNDEFINED || width < 0; + + if (boring == null && + (unconstrainedWidth || + (!YogaConstants.isUndefined(desiredWidth) && desiredWidth <= width))) { + // Is used when the width is not known and the text is not boring, ie. if it contains + // unicode characters. + + int hintWidth = (int) Math.ceil(desiredWidth); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + layout = new StaticLayout( + text, + textPaint, + hintWidth, + Layout.Alignment.ALIGN_NORMAL, + 1.f, + 0.f, + includeFontPadding); + } else { + layout = StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, hintWidth) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLineSpacing(0.f, 1.f) + .setIncludePad(includeFontPadding) + .setBreakStrategy(textBreakStrategy) + .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL) + .build(); + } + + } else if (boring != null && (unconstrainedWidth || boring.width <= width)) { + // Is used for single-line, boring text when the width is either unknown or bigger + // than the width of the text. + layout = BoringLayout.make( + text, + textPaint, + boring.width, + Layout.Alignment.ALIGN_NORMAL, + 1.f, + 0.f, + boring, + includeFontPadding); + } else { + // Is used for multiline, boring text and the width is known. + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + layout = new StaticLayout( + text, + textPaint, + (int) width, + Layout.Alignment.ALIGN_NORMAL, + 1.f, + 0.f, + includeFontPadding); + } else { + layout = StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, (int) width) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLineSpacing(0.f, 1.f) + .setIncludePad(includeFontPadding) + .setBreakStrategy(textBreakStrategy) + .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL) + .build(); + } + } + + int maximumNumberOfLines = paragraphAttributes.hasKey("maximumNumberOfLines") ? paragraphAttributes.getInt("maximumNumberOfLines") : UNSET; + + width = layout.getWidth(); + if (maximumNumberOfLines != UNSET + && maximumNumberOfLines != 0 + && maximumNumberOfLines < layout.getLineCount()) { + height = layout.getLineBottom(maximumNumberOfLines - 1); + } else { + height = layout.getHeight(); + } + + return new float[] { PixelUtil.toSPFromPixel(width), PixelUtil.toSPFromPixel(height) }; + } + + private static class SetSpanOperation { + protected int start, end; + protected Object what; + + SetSpanOperation(int start, int end, Object what) { + this.start = start; + this.end = end; + this.what = what; + } + + public void execute(SpannableStringBuilder 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; + if (start == 0) { + spanFlags = Spannable.SPAN_INCLUSIVE_INCLUSIVE; + } + + spanFlags &= ~Spannable.SPAN_PRIORITY; + spanFlags |= (priority << Spannable.SPAN_PRIORITY_SHIFT) & Spannable.SPAN_PRIORITY; + + sb.setSpan(what, start, end, spanFlags); + } + } +} diff --git a/ReactCommon/fabric/components/text/BUCK b/ReactCommon/fabric/components/text/BUCK index 74bb1a280c4b32..4f241814b6101c 100644 --- a/ReactCommon/fabric/components/text/BUCK +++ b/ReactCommon/fabric/components/text/BUCK @@ -67,6 +67,7 @@ rn_xplat_cxx_library( react_native_xplat_target("fabric/graphics:graphics"), react_native_xplat_target("fabric/textlayoutmanager:textlayoutmanager"), react_native_xplat_target("fabric/components/view:view"), + react_native_xplat_target("fabric/uimanager:uimanager"), ], ) diff --git a/ReactCommon/fabric/components/text/paragraph/ParagraphComponentDescriptor.h b/ReactCommon/fabric/components/text/paragraph/ParagraphComponentDescriptor.h index 7bbdcb02ba7fc4..499efbef8f1295 100644 --- a/ReactCommon/fabric/components/text/paragraph/ParagraphComponentDescriptor.h +++ b/ReactCommon/fabric/components/text/paragraph/ParagraphComponentDescriptor.h @@ -10,6 +10,7 @@ #include #include #include +#include namespace facebook { namespace react { @@ -22,11 +23,11 @@ class ParagraphComponentDescriptor final: public: - ParagraphComponentDescriptor(SharedEventDispatcher eventDispatcher): + ParagraphComponentDescriptor(SharedEventDispatcher eventDispatcher, const SharedContextContainer &contextContainer): ConcreteComponentDescriptor(eventDispatcher) { // Every single `ParagraphShadowNode` will have a reference to // a shared `TextLayoutManager`. - textLayoutManager_ = std::make_shared(); + textLayoutManager_ = std::make_shared(contextContainer); } void adopt(UnsharedShadowNode shadowNode) const override { diff --git a/ReactCommon/fabric/components/text/paragraph/ParagraphShadowNode.cpp b/ReactCommon/fabric/components/text/paragraph/ParagraphShadowNode.cpp index fa570b67fcccf0..781908156cea72 100644 --- a/ReactCommon/fabric/components/text/paragraph/ParagraphShadowNode.cpp +++ b/ReactCommon/fabric/components/text/paragraph/ParagraphShadowNode.cpp @@ -41,6 +41,7 @@ void ParagraphShadowNode::updateLocalData() { Size ParagraphShadowNode::measure(LayoutConstraints layoutConstraints) const { return textLayoutManager_->measure( + getTag(), getAttributedString(), getProps()->paragraphAttributes, layoutConstraints diff --git a/ReactCommon/fabric/textlayoutmanager/BUCK b/ReactCommon/fabric/textlayoutmanager/BUCK index 339be96d051f51..331ea5f5a1f973 100644 --- a/ReactCommon/fabric/textlayoutmanager/BUCK +++ b/ReactCommon/fabric/textlayoutmanager/BUCK @@ -7,6 +7,7 @@ load( "get_apple_compiler_flags", "get_apple_inspector_flags", "react_native_xplat_target", + "react_native_target", "rn_xplat_cxx_library", "subdir_glob", ) @@ -39,6 +40,9 @@ rn_xplat_cxx_library( "-std=c++14", "-Wall", ], + fbandroid_deps = [ + react_native_target("jni/react/jni:jni"), + ], fbandroid_exported_headers = subdir_glob( [ ("", "*.h"), @@ -107,6 +111,7 @@ rn_xplat_cxx_library( react_native_xplat_target("fabric/core:core"), react_native_xplat_target("fabric/debug:debug"), react_native_xplat_target("fabric/graphics:graphics"), + react_native_xplat_target("fabric/uimanager:uimanager"), ], ) diff --git a/ReactCommon/fabric/textlayoutmanager/platform/android/TextLayoutManager.cpp b/ReactCommon/fabric/textlayoutmanager/platform/android/TextLayoutManager.cpp index 02a84d4b4d0275..1759267480c173 100644 --- a/ReactCommon/fabric/textlayoutmanager/platform/android/TextLayoutManager.cpp +++ b/ReactCommon/fabric/textlayoutmanager/platform/android/TextLayoutManager.cpp @@ -7,12 +7,13 @@ #include "TextLayoutManager.h" +#include + +using namespace facebook::jni; + namespace facebook { namespace react { -TextLayoutManager::TextLayoutManager() { -} - TextLayoutManager::~TextLayoutManager() { } @@ -21,12 +22,28 @@ void *TextLayoutManager::getNativeTextLayoutManager() const { } Size TextLayoutManager::measure( + Tag reactTag, AttributedString attributedString, ParagraphAttributes paragraphAttributes, LayoutConstraints layoutConstraints ) const { - // Not implemented. - return {}; + + const jni::global_ref & fabricUIManager = contextContainer_->getInstance>("FabricUIManager"); + + auto clazz = jni::findClassStatic("com/facebook/fbreact/fabricxx/UIManager"); + static auto measure = + clazz->getMethod("measure"); + + int width = (int) layoutConstraints.maximumSize.width; + int height = (int) layoutConstraints.maximumSize.height; + local_ref componentName = make_jstring("RCTText"); + auto values = measure(fabricUIManager, reactTag, componentName.get(), ReadableNativeMap::newObjectCxxArgs(attributedString.toDynamic()).get(), ReadableNativeMap::newObjectCxxArgs(paragraphAttributes.toDynamic()).get(), width, height); + + std::vector indices; + indices.resize(values->size()); + values->getRegion(0, values->size(), indices.data()); + + return {(float) indices[0], (float) indices[1]}; } } // namespace react diff --git a/ReactCommon/fabric/textlayoutmanager/platform/android/TextLayoutManager.h b/ReactCommon/fabric/textlayoutmanager/platform/android/TextLayoutManager.h index d34895cfbd4a1d..74df2977b6c5bc 100644 --- a/ReactCommon/fabric/textlayoutmanager/platform/android/TextLayoutManager.h +++ b/ReactCommon/fabric/textlayoutmanager/platform/android/TextLayoutManager.h @@ -12,6 +12,7 @@ #include #include #include +#include namespace facebook { namespace react { @@ -27,13 +28,14 @@ class TextLayoutManager { public: - TextLayoutManager(); + TextLayoutManager(const SharedContextContainer &contextContainer) : contextContainer_(contextContainer) {}; ~TextLayoutManager(); /* * Measures `attributedString` using native text rendering infrastructure. */ Size measure( + Tag reactTag, AttributedString attributedString, ParagraphAttributes paragraphAttributes, LayoutConstraints layoutConstraints @@ -46,8 +48,10 @@ class TextLayoutManager { void *getNativeTextLayoutManager() const; private: - + void *self_; + + SharedContextContainer contextContainer_; }; } // namespace react diff --git a/ReactCommon/fabric/textlayoutmanager/platform/ios/TextLayoutManager.h b/ReactCommon/fabric/textlayoutmanager/platform/ios/TextLayoutManager.h index 2d7969b5e1d31d..a20c657722dc27 100644 --- a/ReactCommon/fabric/textlayoutmanager/platform/ios/TextLayoutManager.h +++ b/ReactCommon/fabric/textlayoutmanager/platform/ios/TextLayoutManager.h @@ -12,6 +12,7 @@ #include #include #include +#include namespace facebook { namespace react { @@ -25,13 +26,14 @@ using SharedTextLayoutManager = std::shared_ptr; */ class TextLayoutManager { public: - TextLayoutManager(); + TextLayoutManager(const SharedContextContainer &contextContainer); ~TextLayoutManager(); /* * Measures `attributedString` using native text rendering infrastructure. */ Size measure( + Tag reactTag, AttributedString attributedString, ParagraphAttributes paragraphAttributes, LayoutConstraints layoutConstraints diff --git a/ReactCommon/fabric/textlayoutmanager/platform/ios/TextLayoutManager.mm b/ReactCommon/fabric/textlayoutmanager/platform/ios/TextLayoutManager.mm index c87052c1cd6d03..2429596a54fc76 100644 --- a/ReactCommon/fabric/textlayoutmanager/platform/ios/TextLayoutManager.mm +++ b/ReactCommon/fabric/textlayoutmanager/platform/ios/TextLayoutManager.mm @@ -12,7 +12,7 @@ namespace facebook { namespace react { -TextLayoutManager::TextLayoutManager() { +TextLayoutManager::TextLayoutManager(const SharedContextContainer &contextContainer) { self_ = (__bridge_retained void *)[RCTTextLayoutManager new]; } @@ -26,6 +26,7 @@ } Size TextLayoutManager::measure( + Tag reactTag, AttributedString attributedString, ParagraphAttributes paragraphAttributes, LayoutConstraints layoutConstraints