Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ internal object TextLayoutManager {

private const val TEXT_WIDTH_MODE_LONGEST_LINE = "longest-line"

// 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

private val tagToSpannableCache = ConcurrentHashMap<Int, Spannable>()

// These wrappers mirror Android 15 APIs but use reflection because some internal targets still
Expand Down Expand Up @@ -865,7 +869,8 @@ internal object TextLayoutManager {
// layout as a BoringLayout
if (
boring != null &&
(widthYogaMeasureMode == YogaMeasureMode.UNDEFINED || boring.width <= floor(width))
(widthYogaMeasureMode == YogaMeasureMode.UNDEFINED ||
boring.width <= floorPixelWidth(width))
) {
// Guard uses floor() but layout width below uses ceil() for EXACTLY mode intentionally:
// text that barely fails the floor-based guard falls through to StaticLayout, which also
Expand All @@ -888,15 +893,16 @@ internal object TextLayoutManager {
// Layout.getDesiredWidth is a full measuring pass over the text, so only run it when its
// result can affect the layout width. It cannot when the width is EXACTLY, nor when the text
// was boring but did not fit: boring.width is the unwrapped width of the same text and we only
// get here if it exceeds floor(width), so min(desiredWidth, floor(width)) is floor(width).
// get here if it exceeds the floored width, so the minimum of both is the floored width.
val layoutWidth =
when {
widthYogaMeasureMode == YogaMeasureMode.EXACTLY -> ceil(width).toInt()
widthYogaMeasureMode == YogaMeasureMode.AT_MOST && boring != null -> floor(width).toInt()
widthYogaMeasureMode == YogaMeasureMode.AT_MOST && boring != null ->
floorPixelWidth(width)
else -> {
val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()
if (widthYogaMeasureMode == YogaMeasureMode.AT_MOST) {
min(desiredWidth, floor(width).toInt())
min(desiredWidth, floorPixelWidth(width))
} else {
desiredWidth
}
Expand Down Expand Up @@ -970,6 +976,14 @@ internal object TextLayoutManager {
}
}

/**
* Rounds a maximum width down to whole pixels, without losing the last pixel of a width that
* falls a floating point error short of it. Text measured at a whole number of pixels gets that
* width back as its constraint once Yoga has converted it to DIPs and back, and flooring that to
* one pixel less wraps the text.
*/
private fun floorPixelWidth(width: Float): Int = floor(width + PIXEL_SIZE_TOLERANCE).toInt()

@VisibleForTesting
internal fun adjustLayoutForRtlRightOverhang(
layout: Layout,
Expand Down Expand Up @@ -1546,7 +1560,8 @@ internal object TextLayoutManager {

val singleLineTextExceedsWidth = text.length == 1 && layout.getLineWidth(0) > width
val exceedsHeight =
heightYogaMeasureMode != YogaMeasureMode.UNDEFINED && layout.height > height
heightYogaMeasureMode != YogaMeasureMode.UNDEFINED &&
layout.height > height + PIXEL_SIZE_TOLERANCE
val exceedsMaximumNumberOfLines =
maximumNumberOfLines != ReactConstants.UNSET &&
maximumNumberOfLines != 0 &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* 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 android.annotation.SuppressLint
import android.graphics.text.LineBreakConfig
import android.text.Layout
import android.text.SpannableString
import android.text.Spanned
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.ReactAbsoluteSizeSpan
import com.facebook.yoga.YogaMeasureMode
import org.assertj.core.api.Assertions.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.TextLayoutMode

@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@TextLayoutMode(TextLayoutMode.Mode.REALISTIC)
@SuppressLint("InlinedApi")
class TextLayoutManagerFontSizeToFitPixelHeightTest {

@Before
fun setUp() {
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication())
}

@After
fun tearDown() {
DisplayMetricsHolder.setScreenDisplayMetrics(null)
}

@Test
fun `AT_MOST height a floating point error short of the text height keeps the font size`() {
val textHeight = layoutHeight(spannableWithFontSize(FONT_SIZE))

for (height in listOf(Math.nextDown(textHeight.toFloat()), textHeight - 0.0004f)) {
val text = spannableWithFontSize(FONT_SIZE)
adjustToFitHeight(text, height)

assertThat(fontSize(text)).isEqualTo(FONT_SIZE)
}
}

@Test
fun `AT_MOST height a fraction of a pixel short of the text height shrinks the font`() {
val text = spannableWithFontSize(FONT_SIZE)
adjustToFitHeight(text, layoutHeight(text) - 0.5f)

assertThat(fontSize(text)).isLessThan(FONT_SIZE)
}

private fun spannableWithFontSize(fontSize: Int): SpannableString =
SpannableString("Add payment method").apply {
setSpan(ReactAbsoluteSizeSpan(fontSize), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
}

private fun paint(): TextPaint =
TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = FONT_SIZE.toFloat() }

private fun layoutHeight(text: SpannableString): Int =
StaticLayout.Builder.obtain(text, 0, text.length, paint(), WIDTH.toInt())
.setIncludePad(false)
.build()
.height

private fun fontSize(text: SpannableString): Int =
text.getSpans(0, text.length, ReactAbsoluteSizeSpan::class.java).single().size

private fun adjustToFitHeight(text: SpannableString, height: Float) {
TextLayoutManager.adjustSpannableFontToFit(
text,
WIDTH,
YogaMeasureMode.EXACTLY,
height,
YogaMeasureMode.AT_MOST,
/* minimumFontSizeAttr = */ Float.NaN,
/* minimumFontScale = */ Float.NaN,
ReactConstants.UNSET,
/* includeFontPadding = */ false,
Layout.BREAK_STRATEGY_HIGH_QUALITY,
Layout.HYPHENATION_FREQUENCY_NONE,
LineBreakConfig.LINE_BREAK_STYLE_NONE,
LineBreakConfig.LINE_BREAK_WORD_STYLE_NONE,
Layout.Alignment.ALIGN_NORMAL,
/* justificationMode = */ 0,
paint(),
)
}

private companion object {
const val FONT_SIZE = 40
const val WIDTH = 1000f
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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 android.annotation.SuppressLint
import android.graphics.text.LineBreakConfig
import android.text.BoringLayout
import android.text.Layout
import android.text.SpannableString
import android.text.TextPaint
import com.facebook.yoga.YogaMeasureMode
import kotlin.math.ceil
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.TextLayoutMode

@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@TextLayoutMode(TextLayoutMode.Mode.REALISTIC)
class TextLayoutManagerPixelWidthTest {
private val text = SpannableString("Add payment method")
private val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }
private val measuredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()

@Test
fun `AT_MOST width a floating point error short of the measured width keeps one line`() {
val widths = listOf(Math.nextDown(measuredWidth.toFloat()), measuredWidth - 0.0004f)
for (width in widths) {
for (boring in listOf(BoringLayout.isBoring(text, paint), null)) {
val layout = createLayout(width, boring)

assertThat(layout.width).isEqualTo(measuredWidth)
assertThat(layout.lineCount).isEqualTo(1)
}
}
}

@Test
fun `AT_MOST width a fraction of a pixel short of the measured width wraps`() {
for (boring in listOf(BoringLayout.isBoring(text, paint), null)) {
val layout = createLayout(measuredWidth - 0.5f, boring)

assertThat(layout.width).isEqualTo(measuredWidth - 1)
assertThat(layout.lineCount).isGreaterThan(1)
}
}

@SuppressLint("InlinedApi")
private fun createLayout(width: Float, boring: BoringLayout.Metrics?): Layout =
TextLayoutManager.createLayout(
text,
boring,
width,
YogaMeasureMode.AT_MOST,
/* includeFontPadding = */ false,
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
/* lineBreakStyle = */ LineBreakConfig.LINE_BREAK_STYLE_NONE,
/* lineBreakWordStyle = */ LineBreakConfig.LINE_BREAK_WORD_STYLE_NONE,
Layout.Alignment.ALIGN_NORMAL,
/* justificationMode = */ 0,
/* ellipsizeMode = */ null,
/* maxNumberOfLines = */ 0,
paint,
)
}
92 changes: 92 additions & 0 deletions packages/rn-tester/js/examples/Text/TextExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const React = require('react');
const {
Image,
LayoutAnimation,
PixelRatio,
StyleSheet,
Text,
TextInput,
Expand Down Expand Up @@ -1388,6 +1389,65 @@ function TextBaseLineLayoutExample(props: {}): React.Node {
);
}

function FitContentWholePixelWidthExample(props: {}): React.Node {
// Each label is measured, then measured again with its own width as its
// maximum, because its container is only as wide as the label and the
// container's padding. That maximum picks up floating point error from the
// padding and from the conversion to DIPs and back, so it can fall a hair
// short of the label's width. Each row is about a pixel wider than the last.
// No label should wrap.
const rows = [];
for (let i = 0; i < 17; i++) {
rows.push(
<View key={i} style={styles.fitContentRow}>
<View style={styles.fitContentLabelContainer}>
<Text
style={[
styles.fitContentLabel,
{letterSpacing: -0.25 + i / (18 * PixelRatio.get())},
]}>
Add payment method
</Text>
</View>
</View>,
);
}
return <View testID="text-fit-content-whole-pixel-width">{rows}</View>;
}

function AdjustsFontSizeToFitWholePixelHeightExample(props: {}): React.Node {
// Each label's line height is a whole number of pixels, and so is the space
// its container leaves for it below the container's padding. That space
// picks up floating point error from the padding and from the conversion to
// DIPs and back, so it can fall a hair short of the line height. Each row is
// a pixel taller than the last. No label should shrink.
const pixelRatio = PixelRatio.get();
const rows = [];
for (let i = 0; i < 17; i++) {
const lineHeight = (Math.round(20 * pixelRatio) + i) / pixelRatio;
rows.push(
<View
key={i}
style={[
styles.adjustsFontSizeToFitRow,
{height: lineHeight + styles.adjustsFontSizeToFitRow.paddingTop},
]}>
<Text
adjustsFontSizeToFit={true}
numberOfLines={1}
style={[styles.adjustsFontSizeToFitLabel, {lineHeight}]}>
Add payment method
</Text>
</View>,
);
}
return (
<View testID="text-adjusts-font-size-to-fit-whole-pixel-height">
{rows}
</View>
);
}

const examples = [
{
title: 'Wrapped text width mode',
Expand Down Expand Up @@ -1484,6 +1544,16 @@ const examples = [
);
},
},
{
title: 'Fit-content text at whole pixel widths',
name: 'fitContentWholePixelWidth',
render: FitContentWholePixelWidthExample,
},
{
title: 'adjustsFontSizeToFit at whole pixel heights',
name: 'adjustsFontSizeToFitWholePixelHeight',
render: AdjustsFontSizeToFitWholePixelHeightExample,
},
{
title: 'Hyphenation',
name: 'hyphenation',
Expand Down Expand Up @@ -1982,6 +2052,28 @@ const styles = StyleSheet.create({
wrappedText: {
maxWidth: 300,
},
fitContentRow: {
backgroundColor: '#E4E6EB',
flexDirection: 'row',
justifyContent: 'center',
marginBottom: 2,
},
fitContentLabelContainer: {
alignItems: 'center',
paddingHorizontal: 60.15,
},
fitContentLabel: {
fontSize: 15,
maxWidth: '100%',
},
adjustsFontSizeToFitRow: {
backgroundColor: '#E4E6EB',
marginBottom: 2,
paddingTop: 7.1,
},
adjustsFontSizeToFitLabel: {
fontSize: 16,
},
lineBreakSample: {
backgroundColor: '#eee',
fontSize: 16,
Expand Down
Loading