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 @@ -44,6 +44,7 @@ const textViewConfig = {
adjustsFontSizeToFit: true,
minimumFontScale: true,
textBreakStrategy: true,
textWidthMode: true,
onTextLayout: true,
dataDetectorType: true,
android_hyphenationFrequency: true,
Expand Down
9 changes: 9 additions & 0 deletions packages/react-native/Libraries/Text/TextProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,15 @@ type TextBaseProps = Readonly<{
*/
numberOfLines?: ?number,

/**
* Controls how wrapped text contributes its width to layout. `longest-line`
* uses the width of the longest rendered line instead of the wrapping
* constraint.
*
* @default `'default'`
*/
textWidthMode?: ?('default' | 'longest-line'),

onLayout?: ?(event: LayoutChangeEvent) => unknown,

/** Called on long press. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ internal object TextLayoutManager {
const val PA_KEY_MINIMUM_FONT_SIZE: Int = 6
const val PA_KEY_MAXIMUM_FONT_SIZE: Int = 7
const val PA_KEY_TEXT_ALIGN_VERTICAL: Int = 8
const val PA_KEY_TEXT_WIDTH_MODE: Int = 9

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

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

private const val DEFAULT_ADJUST_FONT_SIZE_TO_FIT = false

private const val TEXT_WIDTH_MODE_LONGEST_LINE = "longest-line"

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

// Lazily cached Method for StaticLayout.Builder.setUseBoundsForWidth (API 35+).
Expand Down Expand Up @@ -1065,21 +1068,48 @@ internal object TextLayoutManager {
)
}

var layout = createLayout(
text,
boring,
width,
widthYogaMeasureMode,
includeFontPadding,
textBreakStrategy,
hyphenationFrequency,
alignment,
justificationMode,
ellipsizeMode,
maximumNumberOfLines,
paint,
)

if (
widthYogaMeasureMode == YogaMeasureMode.AT_MOST &&
paragraphAttributes.contains(PA_KEY_TEXT_WIDTH_MODE) &&
paragraphAttributes.getString(PA_KEY_TEXT_WIDTH_MODE) == TEXT_WIDTH_MODE_LONGEST_LINE
) {
val lineCount = calculateLineCount(layout, maximumNumberOfLines)
val longestLineWidth = longestLineWidth(layout, lineCount)
val tightenedWidth = max(1, ceil(longestLineWidth).toInt())
if (tightenedWidth < layout.width) {
layout =
buildLayout(
text,
tightenedWidth,
includeFontPadding,
textBreakStrategy,
hyphenationFrequency,
alignment,
justificationMode,
ellipsizeMode,
maximumNumberOfLines,
paint,
)
}
}

return CreateLayoutResult(
createLayout(
text,
boring,
width,
widthYogaMeasureMode,
includeFontPadding,
textBreakStrategy,
hyphenationFrequency,
alignment,
justificationMode,
ellipsizeMode,
maximumNumberOfLines,
paint,
),
layout,
textBreakStrategy,
justificationMode,
)
Expand Down Expand Up @@ -1471,6 +1501,18 @@ internal object TextLayoutManager {
layout.lineCount
else min(maximumNumberOfLines, layout.lineCount)

@VisibleForTesting
internal fun longestLineWidth(layout: Layout, lineCount: Int): Float {
var longestLineWidth = 0f
for (line in 0 until lineCount) {
val lineEnd = layout.getLineEnd(line)
val endsWithNewLine = lineEnd > 0 && layout.text[lineEnd - 1] == '\n'
val lineWidth = if (endsWithNewLine) layout.getLineMax(line) else layout.getLineWidth(line)
longestLineWidth = max(longestLineWidth, lineWidth)
}
return longestLineWidth
}

private fun calculateWidth(
layout: Layout,
text: Spanned,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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.text.Layout
import android.text.SpannableString
import android.text.StaticLayout
import android.text.TextPaint
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.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class TextLayoutManagerLongestLineWidthTest {

@Test
fun `longest line width tightens a wrapped layout without adding a line`() {
val text = SpannableString("Sitting, Standing,\nRoomscale")
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }
val layout = createLayout(text, paint, 20)

assertThat(layout.lineCount).isGreaterThan(1)

val tightenedWidth = ceil(TextLayoutManager.longestLineWidth(layout, layout.lineCount)).toInt()
val tightenedLayout = createLayout(text, paint, tightenedWidth)

assertThat(tightenedWidth).isLessThan(layout.width)
assertThat(tightenedLayout.lineCount).isEqualTo(layout.lineCount)
assertThat(TextLayoutManager.longestLineWidth(tightenedLayout, tightenedLayout.lineCount))
.isLessThanOrEqualTo(tightenedWidth.toFloat())
}

private fun createLayout(text: SpannableString, paint: TextPaint, width: Int): Layout =
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setBreakStrategy(Layout.BREAK_STRATEGY_HIGH_QUALITY)
.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE)
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ bool ParagraphAttributes::operator==(const ParagraphAttributes& rhs) const {
maximumNumberOfLines,
ellipsizeMode,
textBreakStrategy,
textWidthMode,
adjustsFontSizeToFit,
includeFontPadding,
android_hyphenationFrequency,
Expand All @@ -27,6 +28,7 @@ bool ParagraphAttributes::operator==(const ParagraphAttributes& rhs) const {
rhs.maximumNumberOfLines,
rhs.ellipsizeMode,
rhs.textBreakStrategy,
rhs.textWidthMode,
rhs.adjustsFontSizeToFit,
rhs.includeFontPadding,
rhs.android_hyphenationFrequency,
Expand All @@ -52,6 +54,8 @@ SharedDebugStringConvertibleList ParagraphAttributes::getDebugProps() const {
"textBreakStrategy",
textBreakStrategy,
paragraphAttributes.textBreakStrategy),
debugStringConvertibleItem(
"textWidthMode", textWidthMode, paragraphAttributes.textWidthMode),
debugStringConvertibleItem(
"adjustsFontSizeToFit",
adjustsFontSizeToFit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class ParagraphAttributes : public DebugStringConvertible {
*/
TextBreakStrategy textBreakStrategy{TextBreakStrategy::HighQuality};

TextWidthMode textWidthMode{TextWidthMode::Default};

/*
* Enables font size adjustment to fit constrained boundaries.
*/
Expand Down Expand Up @@ -103,6 +105,7 @@ struct hash<facebook::react::ParagraphAttributes> {
attributes.maximumNumberOfLines,
attributes.ellipsizeMode,
attributes.textBreakStrategy,
attributes.textWidthMode,
attributes.adjustsFontSizeToFit,
attributes.minimumFontSize,
attributes.maximumFontSize,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,42 @@ inline void fromRawValue(const PropsParserContext &context, const RawValue &valu
result = TextBreakStrategy::HighQuality;
}

inline std::string toString(const TextWidthMode &textWidthMode)
{
switch (textWidthMode) {
case TextWidthMode::Default:
return "default";
case TextWidthMode::LongestLine:
return "longest-line";
}

LOG(ERROR) << "Unsupported TextWidthMode value";
react_native_expect(false);
return "default";
}

inline void fromRawValue(const PropsParserContext & /*context*/, const RawValue &value, TextWidthMode &result)
{
react_native_expect(value.hasType<std::string>());
if (value.hasType<std::string>()) {
auto string = (std::string)value;
if (string == "default") {
result = TextWidthMode::Default;
} else if (string == "longest-line") {
result = TextWidthMode::LongestLine;
} else {
LOG(ERROR) << "Unsupported TextWidthMode value: " << string;
react_native_expect(false);
result = TextWidthMode::Default;
}
return;
}

LOG(ERROR) << "Unsupported TextWidthMode type";
react_native_expect(false);
result = TextWidthMode::Default;
}

inline void fromRawValue(const PropsParserContext &context, const RawValue &value, FontWeight &result)
{
react_native_expect(value.hasType<std::string>() || value.hasType<int>());
Expand Down Expand Up @@ -1029,6 +1065,12 @@ inline ParagraphAttributes convertRawProp(
"textBreakStrategy",
sourceParagraphAttributes.textBreakStrategy,
defaultParagraphAttributes.textBreakStrategy);
paragraphAttributes.textWidthMode = convertRawProp(
context,
rawProps,
"textWidthMode",
sourceParagraphAttributes.textWidthMode,
defaultParagraphAttributes.textWidthMode);
paragraphAttributes.adjustsFontSizeToFit = convertRawProp(
context,
rawProps,
Expand Down Expand Up @@ -1158,13 +1200,15 @@ constexpr static MapBuffer::Key PA_KEY_HYPHENATION_FREQUENCY = 5;
constexpr static MapBuffer::Key PA_KEY_MINIMUM_FONT_SIZE = 6;
constexpr static MapBuffer::Key PA_KEY_MAXIMUM_FONT_SIZE = 7;
constexpr static MapBuffer::Key PA_KEY_TEXT_ALIGN_VERTICAL = 8;
constexpr static MapBuffer::Key PA_KEY_TEXT_WIDTH_MODE = 9;

inline MapBuffer toMapBuffer(const ParagraphAttributes &paragraphAttributes)
{
auto builder = MapBufferBuilder();
builder.putInt(PA_KEY_MAX_NUMBER_OF_LINES, paragraphAttributes.maximumNumberOfLines);
builder.putString(PA_KEY_ELLIPSIZE_MODE, toString(paragraphAttributes.ellipsizeMode));
builder.putString(PA_KEY_TEXT_BREAK_STRATEGY, toString(paragraphAttributes.textBreakStrategy));
builder.putString(PA_KEY_TEXT_WIDTH_MODE, toString(paragraphAttributes.textWidthMode));
builder.putBool(PA_KEY_ADJUST_FONT_SIZE_TO_FIT, paragraphAttributes.adjustsFontSizeToFit);
builder.putBool(PA_KEY_INCLUDE_FONT_PADDING, paragraphAttributes.includeFontPadding);
builder.putString(PA_KEY_HYPHENATION_FREQUENCY, toString(paragraphAttributes.android_hyphenationFrequency));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ enum class TextBreakStrategy {
Balanced // Balances line lengths.
};

enum class TextWidthMode {
Default,
LongestLine,
};

enum class TextAlignment {
Natural, // Indicates the default alignment for script.
Left, // Visually left aligned.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,12 @@ TEST(
EXPECT_FALSE(unset == set);
}

TEST(ParagraphAttributesTest, testOperatorEqualsIncludesTextWidthMode) {
ParagraphAttributes defaultWidth{};
ParagraphAttributes longestLineWidth{};
longestLineWidth.textWidthMode = TextWidthMode::LongestLine;

EXPECT_FALSE(defaultWidth == longestLineWidth);
}

} // namespace facebook::react
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ void BaseParagraphProps::setProp(
paragraphAttributes,
textBreakStrategy,
"textBreakStrategy");
REBUILD_FIELD_SWITCH_CASE(
paDefaults, value, paragraphAttributes, textWidthMode, "textWidthMode");
REBUILD_FIELD_SWITCH_CASE(
paDefaults,
value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
CGRect usedBounds = [layoutManager usedRectForTextContainer:textContainer];
CGSize size = usedBounds.size;

if (textDidWrap) {
if (textDidWrap && paragraphAttributes.textWidthMode == TextWidthMode::Default) {
size.width = textContainer.size.width;
}

Expand Down
9 changes: 5 additions & 4 deletions packages/react-native/ReactNativeApi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<cf6a759b9ae1a69151a61e1babd1abd3>>
* @generated SignedSource<<dc468573d684ba68e4a06fb84b02af74>>
*
* This file was generated by scripts/js-api/build-types/index.js.
*/
Expand Down Expand Up @@ -4872,6 +4872,7 @@ declare type TextBaseProps = {
readonly selectable?: boolean
readonly style?: TextStyleProp
readonly testID?: string
readonly textWidthMode?: "default" | "longest-line"
}
declare type TextContentType =
| "addressCity"
Expand Down Expand Up @@ -5756,7 +5757,7 @@ export {
AlertOptions, // 8a116d2a
AlertType, // 5ab91217
AndroidKeyboardEvent, // e03becc8
Animated, // b73ca27a
Animated, // 57868d99
AppConfig, // 35c0ca70
AppRegistry, // eb82174d
AppState, // 12012be5
Expand Down Expand Up @@ -5989,7 +5990,7 @@ export {
TVViewPropsIOS, // 330ce7b5
TargetedEvent, // 16e98910
TaskProvider, // 266dedf2
Text, // 3ccd8020
Text, // ea238168
TextContentType, // 239b3ecc
TextInput, // 89af456b
TextInputAndroidProps, // 9ebbc103
Expand All @@ -6006,7 +6007,7 @@ export {
TextInputSubmitEditingEvent, // 6bcb2aa5
TextInstance, // 05463a96
TextLayoutEvent, // 3f54186f
TextProps, // 2e3336ca
TextProps, // f8e8ca49
TextStyle, // d7678842
ToastAndroid, // 88a8969a
TouchableHighlight, // edab1b07
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ export interface TextProps
*/
numberOfLines?: number | undefined;

/**
* Controls how wrapped text contributes its width to layout.
*/
textWidthMode?: 'default' | 'longest-line' | undefined;

/**
* Invoked on mount and layout changes with
*
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading