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/test/java/com/facebook/react/views/BUCK b/ReactAndroid/src/test/java/com/facebook/react/views/BUCK index 796e0697f6aa12..0787da306422ca 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/views/BUCK +++ b/ReactAndroid/src/test/java/com/facebook/react/views/BUCK @@ -5,6 +5,8 @@ rn_robolectric_test( # TODO (T110934492): Disabled temporarily until tests are fixed # srcs = glob(['**/*.java']), srcs = glob([ + "text/CustomLineHeightSpanTest.java", + "text/CustomStyleSpanTest.java", "image/*.java", "view/*.java", ]), diff --git a/ReactAndroid/src/test/java/com/facebook/react/views/text/CustomStyleSpanTest.java b/ReactAndroid/src/test/java/com/facebook/react/views/text/CustomStyleSpanTest.java new file mode 100644 index 00000000000000..02b10f6a035e01 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/views/text/CustomStyleSpanTest.java @@ -0,0 +1,112 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.content.res.AssetManager; +import android.graphics.Paint; +import android.text.TextPaint; +import com.facebook.react.views.text.CustomStyleSpan.TextAlignVertical; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.rule.PowerMockRule; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +@PrepareForTest({CustomStyleSpan.class}) +@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "androidx.*", "android.*"}) +public class CustomStyleSpanTest { + @Rule public PowerMockRule rule = new PowerMockRule(); + TextPaint tp = mock(TextPaint.class); + Paint.FontMetrics fontMetrics = mock(Paint.FontMetrics.class); + AssetManager assetManager = mock(AssetManager.class); + private int mFontSize; + + @Before + public void setUp() throws Exception { + // https://stackoverflow.com/a/27631737/7295772 + // top ------------- -10 + // ascent ------------- -5 + // baseline __my Text____ 0 + // descent _____________ 2 + // bottom _____________ 5 + tp.baselineShift = 0; + fontMetrics.top = -10.0f; + fontMetrics.ascent = -5.0f; + fontMetrics.descent = 2.0f; + fontMetrics.bottom = 5.0f; + when(tp.getFontMetrics()).thenReturn(fontMetrics); + when(tp.ascent()).thenReturn(fontMetrics.ascent); + when(tp.descent()).thenReturn(fontMetrics.descent); + PowerMockito.whenNew(TextPaint.class).withNoArguments().thenReturn(tp); + } + + private CustomStyleSpan createNewSpan(int fontSize, TextAlignVertical textAlignVertical) { + int fontStyle = 0; + int fontWeight = 0; + PowerMockito.when(tp.getTextSize()).thenReturn((float) fontSize); + return new CustomStyleSpan( + fontStyle, fontWeight, null, null, assetManager, textAlignVertical, fontSize); + } + + // span with no text align vertical or text align vertical center + @Test + public void shouldNotChangeBaseline() { + CustomStyleSpan customStyleSpan = createNewSpan(15, TextAlignVertical.CENTER); + customStyleSpan.updateDrawState(tp); + // uses the default alignment (baseline) + assertThat(tp.baselineShift).isEqualTo(0); + } + + // span has a smaller font then others, textAlignVertical top, line height 10 + @Test + public void textWithSmallerFontSizeAlignsAtTheTopOfTheLineHeight() { + int fontSize = 15; + int lineHeight = 10; + int maximumFontSize = 16; + CustomStyleSpan customStyleSpan = createNewSpan(fontSize, TextAlignVertical.TOP); + customStyleSpan.updateSpan(lineHeight, maximumFontSize); + customStyleSpan.updateDrawState(tp); + // aligns correctly text that has smaller font + int newBaselineShift = + (int) + -(lineHeight / 2 + - maximumFontSize / 2 + // smaller font aligns on the baseline of bigger font + // move the baseline of text with smaller font up + // so it aligns on the top of the larger font + + maximumFontSize + - fontSize + + tp.getFontMetrics().top + - tp.ascent()); + assertThat(tp.baselineShift).isEqualTo(newBaselineShift); + } + + // span has a larger font then others, textAlignVertical bottom, line height 20 + @Test + public void textWithLargerFontSizeAlignsAtTheBottomOfTheLineHeight() { + int fontSize = 20; + int lineHeight = 20; + int maximumFontSize = 20; + CustomStyleSpan customStyleSpan = createNewSpan(fontSize, TextAlignVertical.BOTTOM); + customStyleSpan.updateSpan(lineHeight, maximumFontSize); + customStyleSpan.updateDrawState(tp); + // aligns text vertically in the lineHeight + // and adjust their position depending on the fontSize + int newBaselineShift = (int) (lineHeight / 2 - fontSize / 2 - tp.descent()); + assertThat(tp.baselineShift).isEqualTo(newBaselineShift); + } +}