diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomStyleSpan.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomStyleSpan.java index b249126cf95750..1a379ca443791d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomStyleSpan.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/CustomStyleSpan.java @@ -8,7 +8,6 @@ package com.facebook.react.views.text; import android.content.res.AssetManager; -import android.graphics.Paint; import android.graphics.Typeface; import android.text.TextPaint; import android.text.style.MetricAffectingSpan; @@ -35,6 +34,10 @@ public class CustomStyleSpan extends MetricAffectingSpan implements ReactSpan { private final int mWeight; private final @Nullable String mFeatureSettings; private final @Nullable String mFontFamily; + private int mSize = 0; + private TextAlignVertical mTextAlignVertical = TextAlignVertical.CENTER; + private int mHighestLineHeight = 0; + private int mHighestFontSize = 0; public CustomStyleSpan( int fontStyle, @@ -49,14 +52,61 @@ public CustomStyleSpan( mAssetManager = assetManager; } + public CustomStyleSpan( + int fontStyle, + int fontWeight, + @Nullable String fontFeatureSettings, + @Nullable String fontFamily, + AssetManager assetManager, + TextAlignVertical textAlignVertical, + int textSize) { + this(fontStyle, fontWeight, fontFeatureSettings, fontFamily, assetManager); + mTextAlignVertical = textAlignVertical; + mSize = textSize; + } + + public enum TextAlignVertical { + TOP, + BOTTOM, + CENTER, + } + + public TextAlignVertical getTextAlignVertical() { + return mTextAlignVertical; + } + + public int getSize() { + return mSize; + } + @Override public void updateDrawState(TextPaint ds) { - apply(ds, mStyle, mWeight, mFeatureSettings, mFontFamily, mAssetManager); + apply( + ds, + mStyle, + mWeight, + mFeatureSettings, + mFontFamily, + mAssetManager, + mTextAlignVertical, + mSize, + mHighestLineHeight, + mHighestFontSize); } @Override public void updateMeasureState(TextPaint paint) { - apply(paint, mStyle, mWeight, mFeatureSettings, mFontFamily, mAssetManager); + apply( + paint, + mStyle, + mWeight, + mFeatureSettings, + mFontFamily, + mAssetManager, + mTextAlignVertical, + mSize, + 0, + 0); } public int getStyle() { @@ -72,16 +122,68 @@ public int getWeight() { } private static void apply( - Paint paint, + TextPaint ds, int style, int weight, @Nullable String fontFeatureSettings, @Nullable String family, - AssetManager assetManager) { + AssetManager assetManager, + TextAlignVertical textAlignVertical, + int textSize, + int highestLineHeight, + int highestFontSize) { Typeface typeface = - ReactTypefaceUtils.applyStyles(paint.getTypeface(), style, weight, family, assetManager); - paint.setFontFeatureSettings(fontFeatureSettings); - paint.setTypeface(typeface); - paint.setSubpixelText(true); + ReactTypefaceUtils.applyStyles(ds.getTypeface(), style, weight, family, assetManager); + ds.setFontFeatureSettings(fontFeatureSettings); + ds.setTypeface(typeface); + ds.setSubpixelText(true); + + if (textAlignVertical == TextAlignVertical.CENTER || highestLineHeight == 0) { + return; + } + + // https://stackoverflow.com/a/27631737/7295772 + // top ------------- -10 + // ascent ------------- -5 + // baseline __my Text____ 0 + // descent _____________ 2 + // bottom _____________ 5 + TextPaint textPaintCopy = new TextPaint(); + textPaintCopy.set(ds); + if (textSize > 0) { + textPaintCopy.setTextSize(textSize); + } + + if (textSize == highestFontSize) { + // aligns text vertically in the lineHeight + // and adjust their position depending on the fontSize + if (textAlignVertical == TextAlignVertical.TOP) { + ds.baselineShift -= highestLineHeight / 2 - textPaintCopy.getTextSize() / 2; + } + if (textAlignVertical == TextAlignVertical.BOTTOM) { + ds.baselineShift += + highestLineHeight / 2 - textPaintCopy.getTextSize() / 2 - textPaintCopy.descent(); + } + } else if (highestFontSize != 0 && textSize < highestFontSize) { + // aligns correctly text that has smaller font + if (textAlignVertical == TextAlignVertical.TOP) { + ds.baselineShift -= + highestLineHeight / 2 + - highestFontSize / 2 + // smaller font aligns on the baseline of bigger font + // moves the baseline of text with smaller font up + // so it aligns on the top of the larger font + + (highestFontSize - textSize) + + (textPaintCopy.getFontMetrics().top - textPaintCopy.ascent()); + } + if (textAlignVertical == TextAlignVertical.BOTTOM) { + ds.baselineShift += highestLineHeight / 2 - highestFontSize / 2 - textPaintCopy.descent(); + } + } + } + + public void updateSpan(int highestLineHeight, int highestFontSize) { + mHighestLineHeight = highestLineHeight; + mHighestFontSize = highestFontSize; } } 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 705223f832d4c8..5652a58edad022 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 @@ -103,6 +103,38 @@ public void updateExtraData(ReactTextView view, Object extraData) { ReactAccessibilityDelegate.resetDelegate( view, view.isFocusable(), view.getImportantForAccessibility()); } + + CustomLineHeightSpan[] customLineHeightSpans = + spannable.getSpans(0, spannable.length(), CustomLineHeightSpan.class); + CustomStyleSpan[] customStyleSpans = + spannable.getSpans(0, spannable.length(), CustomStyleSpan.class); + if (customLineHeightSpans.length > 0 && customStyleSpans.length > 0) { + int highestLineHeight = 0; + for (CustomLineHeightSpan span : customLineHeightSpans) { + if (highestLineHeight == 0 || span.getLineHeight() > highestLineHeight) { + highestLineHeight = span.getLineHeight(); + } + } + + int highestFontSize = 0; + if (highestLineHeight != 0) { + for (CustomStyleSpan span : customStyleSpans) { + if (highestFontSize == 0 || span.getSize() > highestFontSize) { + highestFontSize = span.getSize(); + } + } + for (CustomStyleSpan span : customStyleSpans) { + /* + * https://developer.android.com/develop/ui/views/text-and-emoji/spans#change-internal-attributes + * Changes the internal span attribute of a mutable span, such as the bullet color in a custom bullet span, + * you can avoid the overhead from calling setText() multiple times by keeping a reference to the span as it's created. + * When you need to modify the span, you can modify the reference and then call either invalidate() or requestLayout() on the TextView, + * depending on the type of attribute that you changed. + */ + span.updateSpan(highestLineHeight, highestFontSize); + } + } + } } @Override 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 index d503a641808853..305b49bf722131 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java @@ -54,6 +54,7 @@ public class TextAttributeProps { public static final short TA_KEY_IS_HIGHLIGHTED = 20; public static final short TA_KEY_LAYOUT_DIRECTION = 21; public static final short TA_KEY_ACCESSIBILITY_ROLE = 22; + public static final short TA_KEY_ALIGN_VERTICAL = 24; public static final int UNSET = -1; @@ -100,6 +101,7 @@ public class TextAttributeProps { protected boolean mIsUnderlineTextDecorationSet = false; protected boolean mIsLineThroughTextDecorationSet = false; protected boolean mIncludeFontPadding = true; + protected String mTextAlignVertical = "center-child"; protected @Nullable ReactAccessibilityDelegate.AccessibilityRole mAccessibilityRole = null; protected boolean mIsAccessibilityRoleSet = false; @@ -206,6 +208,9 @@ public static TextAttributeProps fromMapBuffer(MapBuffer props) { case TA_KEY_ACCESSIBILITY_ROLE: result.setAccessibilityRole(entry.getStringValue()); break; + case TA_KEY_ALIGN_VERTICAL: + result.setTextAlignVertical(entry.getStringValue()); + break; } } @@ -612,6 +617,16 @@ private void setAccessibilityRole(@Nullable String accessibilityRole) { } } + private void setTextAlignVertical(String alignVertical) { + if (alignVertical.equals("top")) { + mTextAlignVertical = "top-child"; + } else if (alignVertical.equals("center")) { + mTextAlignVertical = "center-child"; + } else if (alignVertical.equals("bottom")) { + mTextAlignVertical = "bottom-child"; + } + } + public static int getTextBreakStrategy(@Nullable String textBreakStrategy) { int androidTextBreakStrategy = DEFAULT_BREAK_STRATEGY; if (textBreakStrategy != null) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java index 9ff17c4359fbe1..a8f3e5d90cf668 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java @@ -162,7 +162,16 @@ private static void buildSpannableFromFragment( new SetSpanOperation(start, end, new ReactAbsoluteSizeSpan(textAttributes.mFontSize))); if (textAttributes.mFontStyle != UNSET || textAttributes.mFontWeight != UNSET - || textAttributes.mFontFamily != null) { + || textAttributes.mFontFamily != null + || textAttributes.mTextAlignVertical != "center-child") { + CustomStyleSpan.TextAlignVertical textAlignVertical = + CustomStyleSpan.TextAlignVertical.CENTER; + if (textAttributes.mTextAlignVertical == "top-child") { + textAlignVertical = CustomStyleSpan.TextAlignVertical.TOP; + } + if (textAttributes.mTextAlignVertical == "bottom-child") { + textAlignVertical = CustomStyleSpan.TextAlignVertical.BOTTOM; + } ops.add( new SetSpanOperation( start, @@ -172,7 +181,9 @@ private static void buildSpannableFromFragment( textAttributes.mFontWeight, textAttributes.mFontFeatureSettings, textAttributes.mFontFamily, - context.getAssets()))); + context.getAssets(), + textAlignVertical, + textAttributes.mFontSize))); } if (textAttributes.mIsUnderlineTextDecorationSet) { ops.add(new SetSpanOperation(start, end, new ReactUnderlineSpan())); diff --git a/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp b/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp index 63b5a4aab19fce..614087aa243288 100644 --- a/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp +++ b/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp @@ -101,6 +101,7 @@ void TextAttributes::apply(TextAttributes textAttributes) { accessibilityRole = textAttributes.accessibilityRole.has_value() ? textAttributes.accessibilityRole : accessibilityRole; + textAlignVertical = !textAttributes.textAlignVertical.empty() ? textAttributes.textAlignVertical : textAlignVertical; } #pragma mark - Operators @@ -126,6 +127,7 @@ bool TextAttributes::operator==(const TextAttributes &rhs) const { isHighlighted, layoutDirection, accessibilityRole, + textAlignVertical, textTransform) == std::tie( rhs.foregroundColor, @@ -147,6 +149,7 @@ bool TextAttributes::operator==(const TextAttributes &rhs) const { rhs.isHighlighted, rhs.layoutDirection, rhs.accessibilityRole, + rhs.textAlignVertical, rhs.textTransform) && floatEquality(opacity, rhs.opacity) && floatEquality(fontSize, rhs.fontSize) && @@ -215,6 +218,7 @@ SharedDebugStringConvertibleList TextAttributes::getDebugProps() const { debugStringConvertibleItem("isHighlighted", isHighlighted), debugStringConvertibleItem("layoutDirection", layoutDirection), debugStringConvertibleItem("accessibilityRole", accessibilityRole), + debugStringConvertibleItem("textAlignVertical", textAlignVertical), }; } #endif diff --git a/ReactCommon/react/renderer/attributedstring/TextAttributes.h b/ReactCommon/react/renderer/attributedstring/TextAttributes.h index f53ad73f60e065..d903e83a227cd1 100644 --- a/ReactCommon/react/renderer/attributedstring/TextAttributes.h +++ b/ReactCommon/react/renderer/attributedstring/TextAttributes.h @@ -81,6 +81,7 @@ class TextAttributes : public DebugStringConvertible { // construction. std::optional layoutDirection{}; std::optional accessibilityRole{}; + std::string textAlignVertical{}; #pragma mark - Operations diff --git a/ReactCommon/react/renderer/attributedstring/conversions.h b/ReactCommon/react/renderer/attributedstring/conversions.h index 367afbfc30007d..aa9fe4d83e45a7 100644 --- a/ReactCommon/react/renderer/attributedstring/conversions.h +++ b/ReactCommon/react/renderer/attributedstring/conversions.h @@ -1036,6 +1036,10 @@ inline folly::dynamic toDynamic(const TextAttributes &textAttributes) { _textAttributes( "accessibilityRole", toString(*textAttributes.accessibilityRole)); } + if (!textAttributes.textAlignVertical.empty()) { + _textAttributes( + "textAlignVertical", textAttributes.textAlignVertical); + } return _textAttributes; } @@ -1110,6 +1114,7 @@ constexpr static MapBuffer::Key TA_KEY_IS_HIGHLIGHTED = 20; constexpr static MapBuffer::Key TA_KEY_LAYOUT_DIRECTION = 21; constexpr static MapBuffer::Key TA_KEY_ACCESSIBILITY_ROLE = 22; constexpr static MapBuffer::Key TA_KEY_LINE_BREAK_STRATEGY = 23; +constexpr static MapBuffer::Key TA_KEY_VERTICAL_ALIGN = 24; // constants for ParagraphAttributes serialization constexpr static MapBuffer::Key PA_KEY_MAX_NUMBER_OF_LINES = 0; @@ -1256,6 +1261,10 @@ inline MapBuffer toMapBuffer(const TextAttributes &textAttributes) { builder.putString( TA_KEY_ACCESSIBILITY_ROLE, toString(*textAttributes.accessibilityRole)); } + if (!textAttributes.textAlignVertical.empty()) { + builder.putString( + TA_KEY_VERTICAL_ALIGN, textAttributes.textAlignVertical); + } return builder.build(); } diff --git a/ReactCommon/react/renderer/components/text/BaseTextProps.cpp b/ReactCommon/react/renderer/components/text/BaseTextProps.cpp index e098e7beab2404..fc2111b62946e3 100644 --- a/ReactCommon/react/renderer/components/text/BaseTextProps.cpp +++ b/ReactCommon/react/renderer/components/text/BaseTextProps.cpp @@ -197,6 +197,13 @@ static TextAttributes convertRawProp( sourceTextAttributes.backgroundColor, defaultTextAttributes.backgroundColor); + textAttributes.textAlignVertical = convertRawProp( + context, + rawProps, + "textAlignVertical", + sourceTextAttributes.textAlignVertical, + defaultTextAttributes.textAlignVertical); + return textAttributes; } @@ -297,6 +304,8 @@ void BaseTextProps::setProp( defaults, value, textAttributes, opacity, "opacity"); REBUILD_FIELD_SWITCH_CASE( defaults, value, textAttributes, backgroundColor, "backgroundColor"); + REBUILD_FIELD_SWITCH_CASE( + defaults, value, textAttributes, textAlignVertical, "textAlignVertical"); } } diff --git a/packages/rn-tester/js/examples/Text/TextExample.android.js b/packages/rn-tester/js/examples/Text/TextExample.android.js index 8234bde9a39088..79dbe61224eba2 100644 --- a/packages/rn-tester/js/examples/Text/TextExample.android.js +++ b/packages/rn-tester/js/examples/Text/TextExample.android.js @@ -18,7 +18,13 @@ const React = require('react'); const TextInlineView = require('../../components/TextInlineView'); import TextLegend from '../../components/TextLegend'; -const {LayoutAnimation, StyleSheet, Text, View} = require('react-native'); +const { + LayoutAnimation, + StyleSheet, + Text, + View, + Button, +} = require('react-native'); class Entity extends React.Component<{|children: React.Node|}> { render(): React.Node { @@ -200,6 +206,118 @@ class AdjustingFontSize extends React.Component< } } +type NestedTextVerticalAlignProps = {||}; +type NestedTextVerticalAlignState = { + textAlignVerticalIndex: number, + fontSize: number, +}; + +class NestedTextVerticalAlign extends React.Component< + NestedTextVerticalAlignProps, + NestedTextVerticalAlignState, +> { + state: NestedTextVerticalAlignState = { + textAlignVerticalIndex: 0, + fontSize: 12, + }; + + _changeVerticalAlign = () => { + this.setState(prevState => { + return { + ...prevState, + textAlignVerticalIndex: prevState.textAlignVerticalIndex + 1, + }; + }); + }; + + _resetVerticalAlign = () => { + this.setState({textAlignVerticalIndex: 0}); + }; + + _increaseFont = () => { + this.setState(prevState => { + return { + ...prevState, + fontSize: prevState.fontSize + 5, + }; + }); + }; + + _resetFont = () => { + this.setState({ + fontSize: 12, + }); + }; + + render(): React.Node { + const {fontSize, textAlignVerticalIndex} = this.state; + const textAlignVerticalOptions = ['center', 'top', 'bottom']; + const textAlignVertical = + textAlignVerticalOptions[textAlignVerticalIndex % 3]; + const textAlignVerticalOppositeSideIndex = + textAlignVerticalIndex === 0 ? 0 : (textAlignVerticalIndex + 2) % 3; + return ( + + + vertical align is set to{' '} + {textAlignVertical} + + + fontSize is set to{' '} + {fontSize} + +