Skip to content

Commit 9b01f82

Browse files
Abbondanzometa-codesync[bot]
authored andcommitted
Add experimental longest-line sizing for wrapped Text (#58351)
Summary: Pull Request resolved: #58351 Add `experimental_textWidthMode` as a Text style for sizing wrapped text to its widest rendered line on Android and iOS. The `auto` value preserves the existing constrained measurement behavior, while `longest-line` removes unused horizontal space without changing the line count. Changelog: [General][Added] - Add experimental longest-line width sizing for wrapped `Text` Reviewed By: javache Differential Revision: D118718410
1 parent 9b87159 commit 9b01f82

27 files changed

Lines changed: 393 additions & 7 deletions

File tree

packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
265265
* Text
266266
*/
267267
color: colorAttribute,
268+
experimental_textWidthMode: true,
268269
fontFamily: true,
269270
fontSize: true,
270271
fontStyle: true,

packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,14 @@ export type ____FontVariationSettings_Internal =
10071007

10081008
type ____TextStyle_InternalBase = Readonly<{
10091009
color?: ____ColorValue_Internal,
1010+
/**
1011+
* Controls how wrapped text contributes its width to layout. `longest-line`
1012+
* uses the width of the longest rendered line instead of the wrapping
1013+
* constraint.
1014+
*
1015+
* @default `'auto'`
1016+
*/
1017+
experimental_textWidthMode?: 'auto' | 'longest-line',
10101018
fontFamily?: string,
10111019
fontSize?: number,
10121020
fontStyle?: 'normal' | 'italic',

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ internal object TextLayoutManager {
9696
const val PA_KEY_MINIMUM_FONT_SIZE: Int = 6
9797
const val PA_KEY_MAXIMUM_FONT_SIZE: Int = 7
9898
const val PA_KEY_TEXT_ALIGN_VERTICAL: Int = 8
99+
const val PA_KEY_TEXT_WIDTH_MODE: Int = 9
99100

100101
private val TAG: String = TextLayoutManager::class.java.simpleName
101102

@@ -110,6 +111,8 @@ internal object TextLayoutManager {
110111

111112
private const val DEFAULT_ADJUST_FONT_SIZE_TO_FIT = false
112113

114+
private const val TEXT_WIDTH_MODE_LONGEST_LINE = "longest-line"
115+
113116
private val tagToSpannableCache = ConcurrentHashMap<Int, Spannable>()
114117

115118
// Lazily cached Method for StaticLayout.Builder.setUseBoundsForWidth (API 35+).
@@ -1065,12 +1068,33 @@ internal object TextLayoutManager {
10651068
)
10661069
}
10671070

1068-
return CreateLayoutResult(
1069-
createLayout(
1071+
var layout = createLayout(
1072+
text,
1073+
boring,
1074+
width,
1075+
widthYogaMeasureMode,
1076+
includeFontPadding,
1077+
textBreakStrategy,
1078+
hyphenationFrequency,
1079+
alignment,
1080+
justificationMode,
1081+
ellipsizeMode,
1082+
maximumNumberOfLines,
1083+
paint,
1084+
)
1085+
1086+
if (
1087+
widthYogaMeasureMode == YogaMeasureMode.AT_MOST &&
1088+
paragraphAttributes.contains(PA_KEY_TEXT_WIDTH_MODE) &&
1089+
paragraphAttributes.getString(PA_KEY_TEXT_WIDTH_MODE) == TEXT_WIDTH_MODE_LONGEST_LINE
1090+
) {
1091+
val lineCount = calculateLineCount(layout, maximumNumberOfLines)
1092+
val longestLineWidth = longestLineWidth(layout, lineCount)
1093+
val tightenedWidth = max(1, ceil(longestLineWidth).toInt())
1094+
if (tightenedWidth < layout.width) {
1095+
val tightenedLayout = buildLayout(
10701096
text,
1071-
boring,
1072-
width,
1073-
widthYogaMeasureMode,
1097+
tightenedWidth,
10741098
includeFontPadding,
10751099
textBreakStrategy,
10761100
hyphenationFrequency,
@@ -1079,7 +1103,15 @@ internal object TextLayoutManager {
10791103
ellipsizeMode,
10801104
maximumNumberOfLines,
10811105
paint,
1082-
),
1106+
)
1107+
if (calculateLineCount(tightenedLayout, maximumNumberOfLines) == lineCount) {
1108+
layout = tightenedLayout
1109+
}
1110+
}
1111+
}
1112+
1113+
return CreateLayoutResult(
1114+
layout,
10831115
textBreakStrategy,
10841116
justificationMode,
10851117
)
@@ -1471,6 +1503,18 @@ internal object TextLayoutManager {
14711503
layout.lineCount
14721504
else min(maximumNumberOfLines, layout.lineCount)
14731505

1506+
@VisibleForTesting
1507+
internal fun longestLineWidth(layout: Layout, lineCount: Int): Float {
1508+
var longestLineWidth = 0f
1509+
for (line in 0 until lineCount) {
1510+
val lineEnd = layout.getLineEnd(line)
1511+
val endsWithNewLine = lineEnd > 0 && layout.text[lineEnd - 1] == '\n'
1512+
val lineWidth = if (endsWithNewLine) layout.getLineMax(line) else layout.getLineWidth(line)
1513+
longestLineWidth = max(longestLineWidth, lineWidth)
1514+
}
1515+
return longestLineWidth
1516+
}
1517+
14741518
private fun calculateWidth(
14751519
layout: Layout,
14761520
text: Spanned,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.text
9+
10+
import android.text.Layout
11+
import android.text.SpannableString
12+
import android.text.StaticLayout
13+
import android.text.TextPaint
14+
import kotlin.math.ceil
15+
import org.assertj.core.api.Assertions.assertThat
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
import org.robolectric.RobolectricTestRunner
19+
import org.robolectric.annotation.Config
20+
21+
@RunWith(RobolectricTestRunner::class)
22+
@Config(sdk = [34])
23+
class TextLayoutManagerLongestLineWidthTest {
24+
25+
@Test
26+
fun `longest line width tightens a wrapped layout without adding a line`() {
27+
val text = SpannableString("Sitting, Standing,\nRoomscale")
28+
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }
29+
val layout = createLayout(text, paint, 20)
30+
31+
assertThat(layout.lineCount).isGreaterThan(1)
32+
33+
val tightenedWidth = ceil(TextLayoutManager.longestLineWidth(layout, layout.lineCount)).toInt()
34+
val tightenedLayout = createLayout(text, paint, tightenedWidth)
35+
36+
assertThat(tightenedWidth).isLessThan(layout.width)
37+
assertThat(tightenedLayout.lineCount).isEqualTo(layout.lineCount)
38+
assertThat(TextLayoutManager.longestLineWidth(tightenedLayout, tightenedLayout.lineCount))
39+
.isLessThanOrEqualTo(tightenedWidth.toFloat())
40+
}
41+
42+
private fun createLayout(text: SpannableString, paint: TextPaint, width: Int): Layout =
43+
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
44+
.setBreakStrategy(Layout.BREAK_STRATEGY_HIGH_QUALITY)
45+
.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE)
46+
.build()
47+
}

packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ bool ParagraphAttributes::operator==(const ParagraphAttributes& rhs) const {
1919
maximumNumberOfLines,
2020
ellipsizeMode,
2121
textBreakStrategy,
22+
textWidthMode,
2223
adjustsFontSizeToFit,
2324
includeFontPadding,
2425
android_hyphenationFrequency,
@@ -27,6 +28,7 @@ bool ParagraphAttributes::operator==(const ParagraphAttributes& rhs) const {
2728
rhs.maximumNumberOfLines,
2829
rhs.ellipsizeMode,
2930
rhs.textBreakStrategy,
31+
rhs.textWidthMode,
3032
rhs.adjustsFontSizeToFit,
3133
rhs.includeFontPadding,
3234
rhs.android_hyphenationFrequency,
@@ -52,6 +54,8 @@ SharedDebugStringConvertibleList ParagraphAttributes::getDebugProps() const {
5254
"textBreakStrategy",
5355
textBreakStrategy,
5456
paragraphAttributes.textBreakStrategy),
57+
debugStringConvertibleItem(
58+
"textWidthMode", textWidthMode, paragraphAttributes.textWidthMode),
5559
debugStringConvertibleItem(
5660
"adjustsFontSizeToFit",
5761
adjustsFontSizeToFit,

packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ class ParagraphAttributes : public DebugStringConvertible {
4848
*/
4949
TextBreakStrategy textBreakStrategy{TextBreakStrategy::HighQuality};
5050

51+
TextWidthMode textWidthMode{TextWidthMode::Auto};
52+
5153
/*
5254
* Enables font size adjustment to fit constrained boundaries.
5355
*/
@@ -105,6 +107,7 @@ struct hash<facebook::react::ParagraphAttributes> {
105107
attributes.maximumNumberOfLines,
106108
attributes.ellipsizeMode,
107109
attributes.textBreakStrategy,
110+
attributes.textWidthMode,
108111
attributes.adjustsFontSizeToFit,
109112
attributes.minimumFontSize,
110113
attributes.maximumFontSize,

packages/react-native/ReactCommon/react/renderer/attributedstring/conversions.h

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,42 @@ inline void fromRawValue(const PropsParserContext &context, const RawValue &valu
204204
result = TextBreakStrategy::HighQuality;
205205
}
206206

207+
inline std::string toString(const TextWidthMode &textWidthMode)
208+
{
209+
switch (textWidthMode) {
210+
case TextWidthMode::Auto:
211+
return "auto";
212+
case TextWidthMode::LongestLine:
213+
return "longest-line";
214+
}
215+
216+
LOG(ERROR) << "Unsupported TextWidthMode value";
217+
react_native_expect(false);
218+
return "auto";
219+
}
220+
221+
inline void fromRawValue(const PropsParserContext & /*context*/, const RawValue &value, TextWidthMode &result)
222+
{
223+
react_native_expect(value.hasType<std::string>());
224+
if (value.hasType<std::string>()) {
225+
auto string = (std::string)value;
226+
if (string == "auto") {
227+
result = TextWidthMode::Auto;
228+
} else if (string == "longest-line") {
229+
result = TextWidthMode::LongestLine;
230+
} else {
231+
LOG(ERROR) << "Unsupported TextWidthMode value: " << string;
232+
react_native_expect(false);
233+
result = TextWidthMode::Auto;
234+
}
235+
return;
236+
}
237+
238+
LOG(ERROR) << "Unsupported TextWidthMode type";
239+
react_native_expect(false);
240+
result = TextWidthMode::Auto;
241+
}
242+
207243
inline void fromRawValue(const PropsParserContext &context, const RawValue &value, FontWeight &result)
208244
{
209245
react_native_expect(value.hasType<std::string>() || value.hasType<int>());
@@ -1031,6 +1067,12 @@ inline ParagraphAttributes convertRawProp(
10311067
"textBreakStrategy",
10321068
sourceParagraphAttributes.textBreakStrategy,
10331069
defaultParagraphAttributes.textBreakStrategy);
1070+
paragraphAttributes.textWidthMode = convertRawProp(
1071+
context,
1072+
rawProps,
1073+
"experimental_textWidthMode",
1074+
sourceParagraphAttributes.textWidthMode,
1075+
defaultParagraphAttributes.textWidthMode);
10341076
paragraphAttributes.adjustsFontSizeToFit = convertRawProp(
10351077
context,
10361078
rawProps,
@@ -1160,13 +1202,15 @@ constexpr static MapBuffer::Key PA_KEY_HYPHENATION_FREQUENCY = 5;
11601202
constexpr static MapBuffer::Key PA_KEY_MINIMUM_FONT_SIZE = 6;
11611203
constexpr static MapBuffer::Key PA_KEY_MAXIMUM_FONT_SIZE = 7;
11621204
constexpr static MapBuffer::Key PA_KEY_TEXT_ALIGN_VERTICAL = 8;
1205+
constexpr static MapBuffer::Key PA_KEY_TEXT_WIDTH_MODE = 9;
11631206

11641207
inline MapBuffer toMapBuffer(const ParagraphAttributes &paragraphAttributes)
11651208
{
11661209
auto builder = MapBufferBuilder();
11671210
builder.putInt(PA_KEY_MAX_NUMBER_OF_LINES, paragraphAttributes.maximumNumberOfLines);
11681211
builder.putString(PA_KEY_ELLIPSIZE_MODE, toString(paragraphAttributes.ellipsizeMode));
11691212
builder.putString(PA_KEY_TEXT_BREAK_STRATEGY, toString(paragraphAttributes.textBreakStrategy));
1213+
builder.putString(PA_KEY_TEXT_WIDTH_MODE, toString(paragraphAttributes.textWidthMode));
11701214
builder.putBool(PA_KEY_ADJUST_FONT_SIZE_TO_FIT, paragraphAttributes.adjustsFontSizeToFit);
11711215
builder.putBool(PA_KEY_INCLUDE_FONT_PADDING, paragraphAttributes.includeFontPadding);
11721216
builder.putString(PA_KEY_HYPHENATION_FREQUENCY, toString(paragraphAttributes.android_hyphenationFrequency));

packages/react-native/ReactCommon/react/renderer/attributedstring/primitives.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ enum class TextBreakStrategy {
9494
Balanced // Balances line lengths.
9595
};
9696

97+
enum class TextWidthMode {
98+
Auto,
99+
LongestLine,
100+
};
101+
97102
enum class TextAlignment {
98103
Natural, // Indicates the default alignment for script.
99104
Left, // Visually left aligned.

packages/react-native/ReactCommon/react/renderer/attributedstring/tests/ParagraphAttributesTest.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#include <gtest/gtest.h>
99
#include <react/renderer/attributedstring/ParagraphAttributes.h>
10+
#include <react/renderer/attributedstring/conversions.h>
1011

1112
namespace facebook::react {
1213

@@ -70,4 +71,16 @@ TEST(
7071
EXPECT_FALSE(unset == set);
7172
}
7273

74+
TEST(ParagraphAttributesTest, testOperatorEqualsIncludesTextWidthMode) {
75+
ParagraphAttributes autoWidth{};
76+
ParagraphAttributes longestLineWidth{};
77+
longestLineWidth.textWidthMode = TextWidthMode::LongestLine;
78+
79+
EXPECT_FALSE(autoWidth == longestLineWidth);
80+
}
81+
82+
TEST(ParagraphAttributesTest, testAutoTextWidthModeSerializesAsAuto) {
83+
EXPECT_EQ(toString(TextWidthMode::Auto), "auto");
84+
}
85+
7386
} // namespace facebook::react

packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphProps.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ void BaseParagraphProps::setProp(
8080
paragraphAttributes,
8181
textBreakStrategy,
8282
"textBreakStrategy");
83+
REBUILD_FIELD_SWITCH_CASE(
84+
paDefaults,
85+
value,
86+
paragraphAttributes,
87+
textWidthMode,
88+
"experimental_textWidthMode");
8389
REBUILD_FIELD_SWITCH_CASE(
8490
paDefaults,
8591
value,

0 commit comments

Comments
 (0)