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"

// Widths arrive in pixels converted from Yoga's floating point DIPs, so a width of 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_WIDTH_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_WIDTH_TOLERANCE).toInt()

@VisibleForTesting
internal fun adjustLayoutForRtlRightOverhang(
layout: Layout,
Expand Down
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,
)
}
46 changes: 46 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,32 @@ 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>;
}

const examples = [
{
title: 'Wrapped text width mode',
Expand Down Expand Up @@ -1484,6 +1511,11 @@ const examples = [
);
},
},
{
title: 'Fit-content text at whole pixel widths',
name: 'fitContentWholePixelWidth',
render: FitContentWholePixelWidthExample,
},
{
title: 'Hyphenation',
name: 'hyphenation',
Expand Down Expand Up @@ -1982,6 +2014,20 @@ 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%',
},
lineBreakSample: {
backgroundColor: '#eee',
fontSize: 16,
Expand Down
Loading