Skip to content

Commit cf1edd5

Browse files
Abbondanzometa-codesync[bot]
authored andcommitted
Add longest-line sizing for wrapped Text (#58351)
Summary: Pull Request resolved: #58351 Add `textWidthMode="longest-line"` to size wrapped `Text` to its widest rendered line on Android and iOS. This keeps content-proportional spacing in horizontal layouts without an `onTextLayout` round trip; the default measurement behavior is unchanged. Changelog: [General][Added] - Add `textWidthMode="longest-line"` for sizing wrapped `Text` to its widest rendered line. Differential Revision: D118718410
1 parent b830082 commit cf1edd5

28 files changed

Lines changed: 387 additions & 19 deletions

File tree

packages/react-native/Libraries/Text/TextNativeComponent.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const textViewConfig = {
4444
adjustsFontSizeToFit: true,
4545
minimumFontScale: true,
4646
textBreakStrategy: true,
47+
textWidthMode: true,
4748
onTextLayout: true,
4849
dataDetectorType: true,
4950
android_hyphenationFrequency: true,

packages/react-native/Libraries/Text/TextProps.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ type TextBaseProps = Readonly<{
181181
*/
182182
numberOfLines?: ?number,
183183

184+
/**
185+
* Controls how wrapped text contributes its width to layout. `longest-line`
186+
* uses the width of the longest rendered line instead of the wrapping
187+
* constraint.
188+
*
189+
* @default `'default'`
190+
*/
191+
textWidthMode?: ?('default' | 'longest-line'),
192+
184193
onLayout?: ?(event: LayoutChangeEvent) => unknown,
185194

186195
/** Called on long press. */

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

Lines changed: 56 additions & 14 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,21 +1068,48 @@ internal object TextLayoutManager {
10651068
)
10661069
}
10671070

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+
layout =
1096+
buildLayout(
1097+
text,
1098+
tightenedWidth,
1099+
includeFontPadding,
1100+
textBreakStrategy,
1101+
hyphenationFrequency,
1102+
alignment,
1103+
justificationMode,
1104+
ellipsizeMode,
1105+
maximumNumberOfLines,
1106+
paint,
1107+
)
1108+
}
1109+
}
1110+
10681111
return CreateLayoutResult(
1069-
createLayout(
1070-
text,
1071-
boring,
1072-
width,
1073-
widthYogaMeasureMode,
1074-
includeFontPadding,
1075-
textBreakStrategy,
1076-
hyphenationFrequency,
1077-
alignment,
1078-
justificationMode,
1079-
ellipsizeMode,
1080-
maximumNumberOfLines,
1081-
paint,
1082-
),
1112+
layout,
10831113
textBreakStrategy,
10841114
justificationMode,
10851115
)
@@ -1471,6 +1501,18 @@ internal object TextLayoutManager {
14711501
layout.lineCount
14721502
else min(maximumNumberOfLines, layout.lineCount)
14731503

1504+
@VisibleForTesting
1505+
internal fun longestLineWidth(layout: Layout, lineCount: Int): Float {
1506+
var longestLineWidth = 0f
1507+
for (line in 0 until lineCount) {
1508+
val lineEnd = layout.getLineEnd(line)
1509+
val endsWithNewLine = lineEnd > 0 && layout.text[lineEnd - 1] == '\n'
1510+
val lineWidth = if (endsWithNewLine) layout.getLineMax(line) else layout.getLineWidth(line)
1511+
longestLineWidth = max(longestLineWidth, lineWidth)
1512+
}
1513+
return longestLineWidth
1514+
}
1515+
14741516
private fun calculateWidth(
14751517
layout: Layout,
14761518
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::Default};
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::Default:
211+
return "default";
212+
case TextWidthMode::LongestLine:
213+
return "longest-line";
214+
}
215+
216+
LOG(ERROR) << "Unsupported TextWidthMode value";
217+
react_native_expect(false);
218+
return "default";
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 == "default") {
227+
result = TextWidthMode::Default;
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::Default;
234+
}
235+
return;
236+
}
237+
238+
LOG(ERROR) << "Unsupported TextWidthMode type";
239+
react_native_expect(false);
240+
result = TextWidthMode::Default;
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+
"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+
Default,
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,12 @@ TEST(
7070
EXPECT_FALSE(unset == set);
7171
}
7272

73+
TEST(ParagraphAttributesTest, testOperatorEqualsIncludesTextWidthMode) {
74+
ParagraphAttributes defaultWidth{};
75+
ParagraphAttributes longestLineWidth{};
76+
longestLineWidth.textWidthMode = TextWidthMode::LongestLine;
77+
78+
EXPECT_FALSE(defaultWidth == longestLineWidth);
79+
}
80+
7381
} // namespace facebook::react

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ void BaseParagraphProps::setProp(
8080
paragraphAttributes,
8181
textBreakStrategy,
8282
"textBreakStrategy");
83+
REBUILD_FIELD_SWITCH_CASE(
84+
paDefaults, value, paragraphAttributes, textWidthMode, "textWidthMode");
8385
REBUILD_FIELD_SWITCH_CASE(
8486
paDefaults,
8587
value,

0 commit comments

Comments
 (0)