com.facebook.react.views.text.ReactTextShadowNode Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of react-native Show documentation
Show all versions of react-native Show documentation
A framework for building native apps with React
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.react.views.text;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import android.graphics.Typeface;
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 android.view.Gravity;
import android.widget.TextView;
import com.facebook.csslayout.CSSDirection;
import com.facebook.csslayout.CSSConstants;
import com.facebook.csslayout.CSSMeasureMode;
import com.facebook.csslayout.CSSNodeDEPRECATED;
import com.facebook.csslayout.CSSNodeAPI;
import com.facebook.csslayout.MeasureOutput;
import com.facebook.csslayout.Spacing;
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.common.annotations.VisibleForTesting;
import com.facebook.react.uimanager.IllegalViewOperationException;
import com.facebook.react.uimanager.LayoutShadowNode;
import com.facebook.react.uimanager.PixelUtil;
import com.facebook.react.uimanager.ReactShadowNode;
import com.facebook.react.uimanager.UIViewOperationQueue;
import com.facebook.react.uimanager.ViewDefaults;
import com.facebook.react.uimanager.ViewProps;
import com.facebook.react.uimanager.annotations.ReactProp;
/**
* {@link ReactShadowNode} class for spannable text view.
*
* This node calculates {@link Spannable} based on subnodes of the same type and passes the
* resulting object down to textview's shadowview and actual native {@link TextView} instance. It is
* important to keep in mind that {@link Spannable} is calculated only on layout step, so if there
* are any text properties that may/should affect the result of {@link Spannable} they should be set
* in a corresponding {@link ReactTextShadowNode}. Resulting {@link Spannable} object is then then
* passed as "computedDataFromMeasure" down to shadow and native view.
*
* TODO(7255858): Rename *CSSNodeDEPRECATED to *ShadowView (or sth similar) as it's no longer is used solely
* for layouting
*/
public class ReactTextShadowNode extends LayoutShadowNode {
private static final String INLINE_IMAGE_PLACEHOLDER = "I";
public static final int UNSET = -1;
@VisibleForTesting
public static final String PROP_TEXT = "text";
public static final String PROP_SHADOW_OFFSET = "textShadowOffset";
public static final String PROP_SHADOW_OFFSET_WIDTH = "width";
public static final String PROP_SHADOW_OFFSET_HEIGHT = "height";
public static final String PROP_SHADOW_RADIUS = "textShadowRadius";
public static final String PROP_SHADOW_COLOR = "textShadowColor";
public static final int DEFAULT_TEXT_SHADOW_COLOR = 0x55000000;
private static final TextPaint sTextPaintInstance = new TextPaint();
static {
sTextPaintInstance.setFlags(TextPaint.ANTI_ALIAS_FLAG);
}
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) {
// 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;
}
sb.setSpan(what, start, end, spanFlags);
}
}
private static void buildSpannedFromTextCSSNode(
ReactTextShadowNode textCSSNode,
SpannableStringBuilder sb,
List ops) {
int start = sb.length();
if (textCSSNode.mText != null) {
sb.append(textCSSNode.mText);
}
for (int i = 0, length = textCSSNode.getChildCount(); i < length; i++) {
CSSNodeDEPRECATED child = textCSSNode.getChildAt(i);
if (child instanceof ReactTextShadowNode) {
buildSpannedFromTextCSSNode((ReactTextShadowNode) 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());
}
((ReactShadowNode) child).markUpdateSeen();
}
int end = sb.length();
if (end >= start) {
if (textCSSNode.mIsColorSet) {
ops.add(new SetSpanOperation(start, end, new ForegroundColorSpan(textCSSNode.mColor)));
}
if (textCSSNode.mIsBackgroundColorSet) {
ops.add(new SetSpanOperation(
start,
end,
new BackgroundColorSpan(textCSSNode.mBackgroundColor)));
}
if (textCSSNode.mFontSize != UNSET) {
ops.add(new SetSpanOperation(start, end, new AbsoluteSizeSpan(textCSSNode.mFontSize)));
}
if (textCSSNode.mFontStyle != UNSET ||
textCSSNode.mFontWeight != UNSET ||
textCSSNode.mFontFamily != null) {
ops.add(new SetSpanOperation(
start,
end,
new CustomStyleSpan(
textCSSNode.mFontStyle,
textCSSNode.mFontWeight,
textCSSNode.mFontFamily,
textCSSNode.getThemedContext().getAssets())));
}
if (textCSSNode.mIsUnderlineTextDecorationSet) {
ops.add(new SetSpanOperation(start, end, new UnderlineSpan()));
}
if (textCSSNode.mIsLineThroughTextDecorationSet) {
ops.add(new SetSpanOperation(start, end, new StrikethroughSpan()));
}
if (textCSSNode.mTextShadowOffsetDx != 0 || textCSSNode.mTextShadowOffsetDy != 0) {
ops.add(new SetSpanOperation(
start,
end,
new ShadowStyleSpan(
textCSSNode.mTextShadowOffsetDx,
textCSSNode.mTextShadowOffsetDy,
textCSSNode.mTextShadowRadius,
textCSSNode.mTextShadowColor)));
}
if (!Float.isNaN(textCSSNode.getEffectiveLineHeight())) {
ops.add(new SetSpanOperation(
start,
end,
new CustomLineHeightSpan(textCSSNode.getEffectiveLineHeight())));
}
ops.add(new SetSpanOperation(start, end, new ReactTagSpan(textCSSNode.getReactTag())));
}
}
protected static Spannable fromTextCSSNode(ReactTextShadowNode textCSSNode) {
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<>();
buildSpannedFromTextCSSNode(textCSSNode, sb, ops);
if (textCSSNode.mFontSize == UNSET) {
sb.setSpan(
new AbsoluteSizeSpan((int) Math.ceil(PixelUtil.toPixelFromSP(ViewDefaults.FONT_SIZE_SP))),
0,
sb.length(),
Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
}
textCSSNode.mContainsImages = false;
textCSSNode.mHeightOfTallestInlineImage = Float.NaN;
// While setting the Spans on the final text, we also check whether any of them are images
for (int i = ops.size() - 1; i >= 0; i--) {
SetSpanOperation op = ops.get(i);
if (op.what instanceof TextInlineImageSpan) {
int height = ((TextInlineImageSpan)op.what).getHeight();
textCSSNode.mContainsImages = true;
if (Float.isNaN(textCSSNode.mHeightOfTallestInlineImage) || height > textCSSNode.mHeightOfTallestInlineImage) {
textCSSNode.mHeightOfTallestInlineImage = height;
}
}
op.execute(sb);
}
return sb;
}
private static final CSSNodeAPI.MeasureFunction TEXT_MEASURE_FUNCTION =
new CSSNodeAPI.MeasureFunction() {
@Override
public long measure(
CSSNodeAPI node,
float width,
CSSMeasureMode widthMode,
float height,
CSSMeasureMode heightMode) {
// TODO(5578671): Handle text direction (see View#getTextDirectionHeuristic)
ReactTextShadowNode reactCSSNode = (ReactTextShadowNode) node;
TextPaint textPaint = sTextPaintInstance;
Layout layout;
Spanned text = Assertions.assertNotNull(
reactCSSNode.mPreparedSpannableText,
"Spannable element has not been prepared in onBeforeLayout");
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 = widthMode == CSSMeasureMode.UNDEFINED || width < 0;
if (boring == null &&
(unconstrainedWidth ||
(!CSSConstants.isUndefined(desiredWidth) && desiredWidth <= width))) {
// Is used when the width is not known and the text is not boring, ie. if it contains
// unicode characters.
layout = new StaticLayout(
text,
textPaint,
(int) Math.ceil(desiredWidth),
Layout.Alignment.ALIGN_NORMAL,
1.f,
0.f,
true);
} 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,
true);
} else {
// Is used for multiline, boring text and the width is known.
layout = new StaticLayout(
text,
textPaint,
(int) width,
Layout.Alignment.ALIGN_NORMAL,
1.f,
0.f,
true);
}
if (reactCSSNode.mNumberOfLines != UNSET &&
reactCSSNode.mNumberOfLines < layout.getLineCount()) {
return MeasureOutput.make(
layout.getWidth(),
layout.getLineBottom(reactCSSNode.mNumberOfLines - 1));
} else {
return MeasureOutput.make(layout.getWidth(), layout.getHeight());
}
}
};
/**
* 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;
}
private float mLineHeight = Float.NaN;
private boolean mIsColorSet = false;
private int mColor;
private boolean mIsBackgroundColorSet = false;
private int mBackgroundColor;
protected int mNumberOfLines = UNSET;
protected int mFontSize = UNSET;
protected int mTextAlign = Gravity.NO_GRAVITY;
private float mTextShadowOffsetDx = 0;
private float mTextShadowOffsetDy = 0;
private float mTextShadowRadius = 1;
private int mTextShadowColor = DEFAULT_TEXT_SHADOW_COLOR;
private boolean mIsUnderlineTextDecorationSet = false;
private boolean mIsLineThroughTextDecorationSet = false;
/**
* mFontStyle can be {@link Typeface#NORMAL} or {@link Typeface#ITALIC}.
* mFontWeight can be {@link Typeface#NORMAL} or {@link Typeface#BOLD}.
*/
private int mFontStyle = UNSET;
private 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
*/
private @Nullable String mFontFamily = null;
private @Nullable String mText = null;
private @Nullable Spannable mPreparedSpannableText;
private final boolean mIsVirtual;
protected boolean mContainsImages = false;
private float mHeightOfTallestInlineImage = Float.NaN;
public ReactTextShadowNode(boolean isVirtual) {
mIsVirtual = isVirtual;
if (!isVirtual) {
setMeasureFunction(TEXT_MEASURE_FUNCTION);
}
}
// 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
private int getTextAlign() {
int textAlign = mTextAlign;
if (getLayoutDirection() == CSSDirection.RTL) {
if (textAlign == Gravity.RIGHT) {
textAlign = Gravity.LEFT;
} else if (textAlign == Gravity.LEFT) {
textAlign = Gravity.RIGHT;
}
}
return textAlign;
}
@Override
public void onBeforeLayout() {
if (mIsVirtual) {
return;
}
mPreparedSpannableText = fromTextCSSNode(this);
markUpdated();
}
@Override
public void markUpdated() {
super.markUpdated();
// We mark virtual anchor node as dirty as updated text needs to be re-measured
if (!mIsVirtual) {
super.dirty();
}
}
@ReactProp(name = PROP_TEXT)
public void setText(@Nullable String text) {
mText = text;
markUpdated();
}
@ReactProp(name = ViewProps.NUMBER_OF_LINES, defaultInt = UNSET)
public void setNumberOfLines(int numberOfLines) {
mNumberOfLines = numberOfLines == 0 ? UNSET : numberOfLines;
markUpdated();
}
@ReactProp(name = ViewProps.LINE_HEIGHT, defaultInt = UNSET)
public void setLineHeight(int lineHeight) {
mLineHeight = lineHeight == UNSET ? Float.NaN : PixelUtil.toPixelFromSP(lineHeight);
markUpdated();
}
@ReactProp(name = ViewProps.TEXT_ALIGN)
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);
}
markUpdated();
}
@ReactProp(name = ViewProps.FONT_SIZE, defaultFloat = UNSET)
public void setFontSize(float fontSize) {
if (fontSize != UNSET) {
fontSize = (float) Math.ceil(PixelUtil.toPixelFromSP(fontSize));
}
mFontSize = (int) fontSize;
markUpdated();
}
@ReactProp(name = ViewProps.COLOR)
public void setColor(@Nullable Integer color) {
mIsColorSet = (color != null);
if (mIsColorSet) {
mColor = color;
}
markUpdated();
}
@ReactProp(name = ViewProps.BACKGROUND_COLOR)
public void setBackgroundColor(Integer color) {
// 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;
}
markUpdated();
}
}
@ReactProp(name = ViewProps.FONT_FAMILY)
public void setFontFamily(@Nullable String fontFamily) {
mFontFamily = fontFamily;
markUpdated();
}
/**
/* This code is duplicated in ReactTextInputManager
/* TODO: Factor into a common place they can both use
*/
@ReactProp(name = ViewProps.FONT_WEIGHT)
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;
markUpdated();
}
}
/**
/* This code is duplicated in ReactTextInputManager
/* TODO: Factor into a common place they can both use
*/
@ReactProp(name = ViewProps.FONT_STYLE)
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;
markUpdated();
}
}
@ReactProp(name = ViewProps.TEXT_DECORATION_LINE)
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;
}
}
}
markUpdated();
}
@ReactProp(name = PROP_SHADOW_OFFSET)
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));
}
}
markUpdated();
}
@ReactProp(name = PROP_SHADOW_RADIUS, defaultInt = 1)
public void setTextShadowRadius(float textShadowRadius) {
if (textShadowRadius != mTextShadowRadius) {
mTextShadowRadius = textShadowRadius;
markUpdated();
}
}
@ReactProp(name = PROP_SHADOW_COLOR, defaultInt = DEFAULT_TEXT_SHADOW_COLOR, customType = "Color")
public void setTextShadowColor(int textShadowColor) {
if (textShadowColor != mTextShadowColor) {
mTextShadowColor = textShadowColor;
markUpdated();
}
}
@Override
public boolean isVirtualAnchor() {
return !mIsVirtual;
}
@Override
public boolean isVirtual() {
return mIsVirtual;
}
@Override
public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) {
if (mIsVirtual) {
return;
}
super.onCollectExtraUpdates(uiViewOperationQueue);
if (mPreparedSpannableText != null) {
ReactTextUpdate reactTextUpdate =
new ReactTextUpdate(
mPreparedSpannableText,
UNSET,
mContainsImages,
getPadding(Spacing.START),
getPadding(Spacing.TOP),
getPadding(Spacing.END),
getPadding(Spacing.BOTTOM),
getTextAlign()
);
uiViewOperationQueue.enqueueUpdateExtraData(getReactTag(), reactTextUpdate);
}
}
}