diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index ce31988c6cbe..5141c936ec8c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -121,7 +121,7 @@ internal object TextLayoutManager { // Sizes arrive in pixels converted from Yoga's floating point DIPs, so a whole number of pixels // can arrive a fraction of a pixel short of it (e.g. 371.99997 instead of 372). - private const val PIXEL_SIZE_TOLERANCE = 0.001f + internal const val PIXEL_SIZE_TOLERANCE: Float = 0.001f private val tagToSpannableCache = ConcurrentHashMap() @@ -1497,6 +1497,27 @@ internal object TextLayoutManager { ) } + /** + * How much taller rounding line heights up to whole pixels made a layout than the line heights + * its text asks for. StaticLayout applies every line height span in a paragraph to each of its + * lines, and the last one sets the line's height. + */ + private fun lineHeightRounding(layout: Layout): Float { + val text = layout.text as? Spanned ?: return 0f + var rounding = 0f + for (line in 0 until layout.lineCount) { + val lineStart = layout.getLineStart(line) + val paragraphStart = TextUtils.lastIndexOf(text, '\n', lineStart - 1) + 1 + val paragraphEnd = + TextUtils.indexOf(text, '\n', lineStart).let { if (it < 0) text.length else it } + val span = + text.getSpans(paragraphStart, paragraphEnd, CustomLineHeightSpan::class.java).lastOrNull() + ?: continue + rounding += span.lineHeight - span.height + } + return rounding + } + @JvmStatic fun adjustSpannableFontToFit( text: Spannable, @@ -1593,7 +1614,8 @@ internal object TextLayoutManager { val singleLineTextExceedsWidth = text.length == 1 && layout.getLineWidth(0) > width val exceedsHeight = heightYogaMeasureMode != YogaMeasureMode.UNDEFINED && - layout.height > height + PIXEL_SIZE_TOLERANCE + layout.height > height + PIXEL_SIZE_TOLERANCE && + layout.height - lineHeightRounding(layout) > height + PIXEL_SIZE_TOLERANCE val exceedsMaximumNumberOfLines = maximumNumberOfLines != ReactConstants.UNSET && maximumNumberOfLines != 0 && diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt index 91702c7c9619..1eb71469404a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt @@ -12,6 +12,7 @@ import android.text.Spanned import android.text.TextPaint import android.text.style.LineHeightSpan import android.text.style.MetricAffectingSpan +import com.facebook.react.views.text.TextLayoutManager import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max @@ -25,10 +26,12 @@ import kotlin.math.max * holding an inline view grow to contain it, as CSS does for inline replaced elements. */ internal class CustomLineHeightSpan( - height: Float, + val height: Float, private val inlineViews: InlineViewHeights? = null, ) : LineHeightSpan.WithDensity, ReactSpan { - val lineHeight: Int = ceil(height.toDouble()).toInt() + // Lines are laid out a whole number of pixels tall, so round the height up, but not past a whole + // number of pixels it only exceeds by floating point error. + val lineHeight: Int = ceil(height - TextLayoutManager.PIXEL_SIZE_TOLERANCE).toInt() override fun chooseHeight( text: CharSequence, diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontSizeToFitPixelHeightTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontSizeToFitPixelHeightTest.kt index ce2669d8b388..c32d283470d6 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontSizeToFitPixelHeightTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontSizeToFitPixelHeightTest.kt @@ -16,6 +16,7 @@ import android.text.StaticLayout import android.text.TextPaint import com.facebook.react.common.ReactConstants import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.views.text.internal.span.CustomLineHeightSpan import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan import com.facebook.yoga.YogaMeasureMode import org.assertj.core.api.Assertions.assertThat @@ -64,6 +65,30 @@ class TextLayoutManagerFontSizeToFitPixelHeightTest { assertThat(fontSize(text)).isLessThan(FONT_SIZE) } + @Test + fun `line heights rounded up to whole pixels still fit the height of their lines`() { + for (lineCount in 1..2) { + val text = spannableWithLineHeight(FRACTIONAL_LINE_HEIGHT, lineCount) + adjustToFitHeight(text, lineCount * FRACTIONAL_LINE_HEIGHT) + + assertThat(fontSize(text)).isEqualTo(FONT_SIZE) + } + } + + @Test + fun `line heights that do not fit the height of their lines shrink the font`() { + val text = spannableWithLineHeight(FRACTIONAL_LINE_HEIGHT, lineCount = 2) + adjustToFitHeight(text, 2 * FRACTIONAL_LINE_HEIGHT - 1f) + + assertThat(fontSize(text)).isLessThan(FONT_SIZE) + } + + private fun spannableWithLineHeight(lineHeight: Float, lineCount: Int): SpannableString = + SpannableString(List(lineCount) { "Add payment method" }.joinToString("\n")).apply { + setSpan(ReactAbsoluteSizeSpan(FONT_SIZE), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + setSpan(CustomLineHeightSpan(lineHeight), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + private fun spannableWithFontSize(fontSize: Int): SpannableString = SpannableString("Add payment method").apply { setSpan(ReactAbsoluteSizeSpan(fontSize), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) @@ -104,6 +129,7 @@ class TextLayoutManagerFontSizeToFitPixelHeightTest { private companion object { const val FONT_SIZE = 40 + const val FRACTIONAL_LINE_HEIGHT = 52.5f const val WIDTH = 1000f } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt index c8233b7a7166..58a02419e845 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpanTest.kt @@ -78,6 +78,18 @@ class CustomLineHeightSpanTest { assertThat(layout.height).isEqualTo(24) } + @Test + fun fractionalLineHeightRoundsUpToWholePixels() { + assertThat(CustomLineHeightSpan(42.5f).lineHeight).isEqualTo(43) + assertThat(CustomLineHeightSpan(44.01f).lineHeight).isEqualTo(45) + } + + @Test + fun lineHeightFloatingPointErrorAboveWholePixelsDoesNotRoundUp() { + assertThat(CustomLineHeightSpan(Math.nextUp(44f)).lineHeight).isEqualTo(44) + assertThat(CustomLineHeightSpan(44.0004f).lineHeight).isEqualTo(44) + } + @Test fun inlineViewTallerThanLineHeightGrowsLineBoxAroundTextAndView() { val layout = buildStaticLayout("ab\uFFFC", lineHeight = 24, inlineViewHeight = 40) diff --git a/packages/rn-tester/js/examples/Text/TextExample.android.js b/packages/rn-tester/js/examples/Text/TextExample.android.js index c49b24d9197d..f18134d30e81 100644 --- a/packages/rn-tester/js/examples/Text/TextExample.android.js +++ b/packages/rn-tester/js/examples/Text/TextExample.android.js @@ -1448,6 +1448,39 @@ function AdjustsFontSizeToFitWholePixelHeightExample(props: {}): React.Node { ); } +function AdjustsFontSizeToFitLineHeightExample(props: {}): React.Node { + // Each label's lines exactly fill its container. Android lays lines out a + // whole number of pixels tall, so it rounds up a line height that isn't one. + // Each row's line height is half a pixel taller than the last, so it + // alternates between whole and fractional pixels, and some of the whole ones + // convert to a hair over a whole number of pixels. No label should shrink. + const pixelRatio = PixelRatio.get(); + const rows = []; + for (const numberOfLines of [1, 2]) { + for (let i = 0; i < 9; i++) { + const lineHeight = (Math.round(16 * pixelRatio) + i / 2) / pixelRatio; + rows.push( + 1 && + styles.adjustsFontSizeToFitLineHeightWrappingRow, + {height: numberOfLines * lineHeight}, + ]}> + + Add payment method + + , + ); + } + } + return {rows}; +} + const examples = [ { title: 'Wrapped text width mode', @@ -1554,6 +1587,11 @@ const examples = [ name: 'adjustsFontSizeToFitWholePixelHeight', render: AdjustsFontSizeToFitWholePixelHeightExample, }, + { + title: 'adjustsFontSizeToFit with lineHeight', + name: 'adjustsFontSizeToFitLineHeight', + render: AdjustsFontSizeToFitLineHeightExample, + }, { title: 'Hyphenation', name: 'hyphenation', @@ -2081,6 +2119,16 @@ const styles = StyleSheet.create({ adjustsFontSizeToFitLabel: { fontSize: 16, }, + adjustsFontSizeToFitLineHeightRow: { + backgroundColor: '#E4E6EB', + marginBottom: 2, + }, + adjustsFontSizeToFitLineHeightWrappingRow: { + width: 90, + }, + adjustsFontSizeToFitLineHeightLabel: { + fontSize: 12, + }, lineBreakSample: { backgroundColor: '#eee', fontSize: 16,