Skip to content
Closed
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 @@ -265,6 +265,7 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
* Text
*/
color: colorAttribute,
experimental_textWidthMode: true,
fontFamily: true,
fontSize: true,
fontStyle: true,
Expand Down
8 changes: 8 additions & 0 deletions packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,14 @@ export type ____FontVariationSettings_Internal =

type ____TextStyle_InternalBase = Readonly<{
color?: ____ColorValue_Internal,
/**
* 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 `'auto'`
*/
experimental_textWidthMode?: 'auto' | 'longest-line',
fontFamily?: string,
fontSize?: number,
fontStyle?: 'normal' | 'italic',
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,12 +1068,33 @@ internal object TextLayoutManager {
)
}

return CreateLayoutResult(
createLayout(
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) {
val tightenedLayout = buildLayout(
text,
boring,
width,
widthYogaMeasureMode,
tightenedWidth,
includeFontPadding,
textBreakStrategy,
hyphenationFrequency,
Expand All @@ -1079,7 +1103,15 @@ internal object TextLayoutManager {
ellipsizeMode,
maximumNumberOfLines,
paint,
),
)
if (calculateLineCount(tightenedLayout, maximumNumberOfLines) == lineCount) {
layout = tightenedLayout
}
}
}

return CreateLayoutResult(
layout,
textBreakStrategy,
justificationMode,
)
Expand Down Expand Up @@ -1471,6 +1503,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 @@ -48,6 +48,8 @@ class ParagraphAttributes : public DebugStringConvertible {
*/
TextBreakStrategy textBreakStrategy{TextBreakStrategy::HighQuality};

TextWidthMode textWidthMode{TextWidthMode::Auto};

/*
* Enables font size adjustment to fit constrained boundaries.
*/
Expand Down Expand Up @@ -105,6 +107,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 @@ -204,6 +204,42 @@ inline void fromRawValue(const PropsParserContext &context, const RawValue &valu
result = TextBreakStrategy::HighQuality;
}

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

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

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 == "auto") {
result = TextWidthMode::Auto;
} else if (string == "longest-line") {
result = TextWidthMode::LongestLine;
} else {
LOG(ERROR) << "Unsupported TextWidthMode value: " << string;
react_native_expect(false);
result = TextWidthMode::Auto;
}
return;
}

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

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 @@ -1031,6 +1067,12 @@ inline ParagraphAttributes convertRawProp(
"textBreakStrategy",
sourceParagraphAttributes.textBreakStrategy,
defaultParagraphAttributes.textBreakStrategy);
paragraphAttributes.textWidthMode = convertRawProp(
context,
rawProps,
"experimental_textWidthMode",
sourceParagraphAttributes.textWidthMode,
defaultParagraphAttributes.textWidthMode);
paragraphAttributes.adjustsFontSizeToFit = convertRawProp(
context,
rawProps,
Expand Down Expand Up @@ -1160,13 +1202,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 @@ -94,6 +94,11 @@ enum class TextBreakStrategy {
Balanced // Balances line lengths.
};

enum class TextWidthMode {
Auto,
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 @@ -7,6 +7,7 @@

#include <gtest/gtest.h>
#include <react/renderer/attributedstring/ParagraphAttributes.h>
#include <react/renderer/attributedstring/conversions.h>

namespace facebook::react {

Expand Down Expand Up @@ -70,4 +71,16 @@ TEST(
EXPECT_FALSE(unset == set);
}

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

EXPECT_FALSE(autoWidth == longestLineWidth);
}

TEST(ParagraphAttributesTest, testAutoTextWidthModeSerializesAsAuto) {
EXPECT_EQ(toString(TextWidthMode::Auto), "auto");
}

} // namespace facebook::react
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ void BaseParagraphProps::setProp(
paragraphAttributes,
textBreakStrategy,
"textBreakStrategy");
REBUILD_FIELD_SWITCH_CASE(
paDefaults,
value,
paragraphAttributes,
textWidthMode,
"experimental_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::Auto) {
size.width = textContainer.size.width;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,10 @@ export type FontVariationSettings = string | Readonly<Record<string, number>>;

export interface TextStyle extends TextStyleIOS, TextStyleAndroid, ViewStyle {
color?: ColorValue | undefined;
/**
* Controls how wrapped text contributes its width to layout.
*/
experimental_textWidthMode?: 'auto' | 'longest-line' | undefined;
fontFamily?: string | undefined;
fontSize?: number | undefined;
fontStyle?: 'normal' | 'italic' | undefined;
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.
27 changes: 27 additions & 0 deletions packages/rn-tester/.maestro/text-width-mode.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
appId: ${APP_ID}
tags:
- local-screenshot-baseline
---
- runFlow: ./helpers/launch-app-and-search.yml
- inputText: 'Text'
- assertVisible:
id: 'Text'
- tapOn:
id: 'Text'
- assertVisible:
id: 'example_search'
- tapOn:
id: 'example_search'
- inputText: 'Wrapped text width mode'
- hideKeyboard
- scrollUntilVisible:
element:
id: 'text-width-mode-example'
direction: DOWN
speed: 40
timeout: 10000
- assertScreenshot:
path: screenshots/text-width-mode-${maestro.platform}
cropOn:
id: 'text-width-mode-example'
thresholdPercentage: 95
8 changes: 8 additions & 0 deletions packages/rn-tester/js/examples/Text/TextExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import RNTesterText from '../../components/RNTesterText';
import TextLegend from '../../components/TextLegend';
import TextAdjustsDynamicLayoutExample from './TextAdjustsDynamicLayoutExample';
import TextSharedExamples from './TextSharedExamples';
import TextWidthModeExample from './TextWidthModeExample';

const TextInlineView = require('../../components/TextInlineView');
const React = require('react');
Expand Down Expand Up @@ -1336,6 +1337,13 @@ function TextBaseLineLayoutExample(props: {}): React.Node {
}

const examples = [
{
title: 'Wrapped text width mode',
name: 'textWidthMode',
description:
'Compares automatic constrained text width with text sized to its longest rendered line.',
render: TextWidthModeExample,
},
{
title: 'Background Color and Border Width',
name: 'background-border-width',
Expand Down
Loading
Loading