From 1169286227543c2bdf8645c78c63e91b887d7409 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Thu, 29 Oct 2026 10:39:03 +0100 Subject: [PATCH 1/2] Draw ellipsized text without shaping its hidden text on Android The last line of an ellipsized layout keeps all the text after the ellipsis, as zero-width characters, and Android shapes all of it every time the layout is drawn. For a long text cut to a few lines, that is most of the work of drawing it, on the main thread. Behind the new enableEllipsizedTextTrimmingAndroid flag, when a prepared layout is ellipsized at the end, wrap it in EllipsisTrimmedLayout, a delegating Layout like MutableSpannableLayout whose text stops right after the ellipsis character and whose line metrics come from the original layout, so nothing is laid out again. The original layout is kept when the text is justified or hyphenated, has right-to-left characters, is centered or end-aligned with letter spacing in its hidden part, or when its hidden part has links, replacement spans (such as inline views), text effects or touchable spans. Selectable text, which lays the text out again in a TextView, still gets the whole text. Co-Authored-By: Claude Opus 5.5 --- .../featureflags/ReactNativeFeatureFlags.kt | 8 +- .../ReactNativeFeatureFlagsCxxAccessor.kt | 12 +- .../ReactNativeFeatureFlagsCxxInterop.kt | 4 +- .../ReactNativeFeatureFlagsDefaults.kt | 4 +- .../ReactNativeFeatureFlagsLocalAccessor.kt | 13 +- .../ReactNativeFeatureFlagsProvider.kt | 4 +- .../react/views/text/EllipsisTrimmedLayout.kt | 152 ++++++++ .../react/views/text/ReactTextViewManager.kt | 4 +- .../react/views/text/TextLayoutManager.kt | 19 +- .../JReactNativeFeatureFlagsCxxInterop.cpp | 16 +- .../JReactNativeFeatureFlagsCxxInterop.h | 5 +- .../views/text/EllipsisTrimmedLayoutTest.kt | 335 ++++++++++++++++++ .../featureflags/ReactNativeFeatureFlags.cpp | 6 +- .../featureflags/ReactNativeFeatureFlags.h | 7 +- .../ReactNativeFeatureFlagsAccessor.cpp | 166 +++++---- .../ReactNativeFeatureFlagsAccessor.h | 6 +- .../ReactNativeFeatureFlagsDefaults.h | 6 +- .../ReactNativeFeatureFlagsDynamicProvider.h | 11 +- .../ReactNativeFeatureFlagsProvider.h | 3 +- .../NativeReactNativeFeatureFlags.cpp | 7 +- .../NativeReactNativeFeatureFlags.h | 4 +- .../ReactNativeFeatureFlags.config.js | 11 + .../featureflags/ReactNativeFeatureFlags.js | 7 +- .../specs/NativeReactNativeFeatureFlags.js | 3 +- 24 files changed, 718 insertions(+), 95 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EllipsisTrimmedLayout.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/EllipsisTrimmedLayoutTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 7fcdaaeec16d..aa1d7eed924b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -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<> + * @generated SignedSource<<2143f0faa5bbc2cc77e304adc3edf65c>> */ /** @@ -174,6 +174,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun enableEagerRootViewAttachment(): Boolean = accessor.enableEagerRootViewAttachment() + /** + * Draws ellipsized text on Android without the text hidden after the ellipsis, so that it is not shaped again on every draw. + */ + @JvmStatic + public fun enableEllipsizedTextTrimmingAndroid(): Boolean = accessor.enableEllipsizedTextTrimmingAndroid() + /** * When enabled, Android will disable Props 1.5 raw value merging when Props 2.0 is available. */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index 6e0da4d9d0e4..3107e7bbfab1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt @@ -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<> + * @generated SignedSource<> */ /** @@ -44,6 +44,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var enableDestroyShadowTreeRevisionAsyncCache: Boolean? = null private var enableDoubleMeasurementFixAndroidCache: Boolean? = null private var enableEagerRootViewAttachmentCache: Boolean? = null + private var enableEllipsizedTextTrimmingAndroidCache: Boolean? = null private var enableExclusivePropsUpdateAndroidCache: Boolean? = null private var enableFabricCommitBranchingCache: Boolean? = null private var enableFabricCommitBranchingMergeOnMainThreadCache: Boolean? = null @@ -334,6 +335,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun enableEllipsizedTextTrimmingAndroid(): Boolean { + var cached = enableEllipsizedTextTrimmingAndroidCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.enableEllipsizedTextTrimmingAndroid() + enableEllipsizedTextTrimmingAndroidCache = cached + } + return cached + } + override fun enableExclusivePropsUpdateAndroid(): Boolean { var cached = enableExclusivePropsUpdateAndroidCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index 2268d03f3674..1a08f42fd885 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt @@ -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<<6d86d4adbc261c9271b5bed72bd12dcf>> + * @generated SignedSource<<2f52b0ca227f2fc6e797caf49e810082>> */ /** @@ -76,6 +76,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun enableEagerRootViewAttachment(): Boolean + @DoNotStrip @JvmStatic public external fun enableEllipsizedTextTrimmingAndroid(): Boolean + @DoNotStrip @JvmStatic public external fun enableExclusivePropsUpdateAndroid(): Boolean @DoNotStrip @JvmStatic public external fun enableFabricCommitBranching(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index defafe46f6b4..e0c9fcc3c70f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -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<> + * @generated SignedSource<> */ /** @@ -71,6 +71,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun enableEagerRootViewAttachment(): Boolean = false + override fun enableEllipsizedTextTrimmingAndroid(): Boolean = false + override fun enableExclusivePropsUpdateAndroid(): Boolean = false override fun enableFabricCommitBranching(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index 53af4b4edab0..01846f0d7207 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt @@ -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<> + * @generated SignedSource<<40431db1f1e82e88b4209490990137dd>> */ /** @@ -48,6 +48,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var enableDestroyShadowTreeRevisionAsyncCache: Boolean? = null private var enableDoubleMeasurementFixAndroidCache: Boolean? = null private var enableEagerRootViewAttachmentCache: Boolean? = null + private var enableEllipsizedTextTrimmingAndroidCache: Boolean? = null private var enableExclusivePropsUpdateAndroidCache: Boolean? = null private var enableFabricCommitBranchingCache: Boolean? = null private var enableFabricCommitBranchingMergeOnMainThreadCache: Boolean? = null @@ -362,6 +363,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun enableEllipsizedTextTrimmingAndroid(): Boolean { + var cached = enableEllipsizedTextTrimmingAndroidCache + if (cached == null) { + cached = currentProvider.enableEllipsizedTextTrimmingAndroid() + accessedFeatureFlags.add("enableEllipsizedTextTrimmingAndroid") + enableEllipsizedTextTrimmingAndroidCache = cached + } + return cached + } + override fun enableExclusivePropsUpdateAndroid(): Boolean { var cached = enableExclusivePropsUpdateAndroidCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index 8fa2f4269464..7b4fdce8418a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt @@ -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<> + * @generated SignedSource<<02e60f60410efd918ad09ffc46a3bd3d>> */ /** @@ -71,6 +71,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun enableEagerRootViewAttachment(): Boolean + @DoNotStrip public fun enableEllipsizedTextTrimmingAndroid(): Boolean + @DoNotStrip public fun enableExclusivePropsUpdateAndroid(): Boolean @DoNotStrip public fun enableFabricCommitBranching(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EllipsisTrimmedLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EllipsisTrimmedLayout.kt new file mode 100644 index 000000000000..abd122709b02 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EllipsisTrimmedLayout.kt @@ -0,0 +1,152 @@ +/* + * 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.Spanned +import android.text.TextDirectionHeuristics +import android.text.style.ClickableSpan +import android.text.style.ReplacementSpan +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan +import com.facebook.react.views.text.internal.span.TextEffectSpan +import com.facebook.react.views.text.internal.span.TouchableSpan + +/** + * A delegating [Layout] over the text of a [delegate] ellipsized at the end, without the text + * hidden after the ellipsis. + * + * The last line of an ellipsized layout keeps all the text after the ellipsis, as zero-width + * characters, and Android shapes all of it every time the layout is drawn. This layout's text stops + * right after the ellipsis character, and its line metrics are delegated to [delegate], like in + * [MutableSpannableLayout], so it draws the same visible text without laying anything out again. + */ +internal class EllipsisTrimmedLayout +private constructor( + private val delegate: Layout, + trimmedText: SpannableString, +) : + Layout( + trimmedText, + delegate.paint, + delegate.width, + delegate.alignment, + delegate.spacingMultiplier, + delegate.spacingAdd, + ) { + + private val lastLine = delegate.lineCount - 1 + + /** The layout this one draws, with the whole text. */ + val originalLayout: Layout + get() = delegate + + companion object { + /** + * Returns an [EllipsisTrimmedLayout] if [layout], a layout of [source] that isn't justified or + * hyphenated, is ellipsized at the end and hides text that only matters for drawing, else null. + */ + @OptIn(UnstableReactNativeAPI::class) + fun createIfNeeded(layout: Layout, source: Spanned): EllipsisTrimmedLayout? { + val lastLine = layout.lineCount - 1 + if (lastLine < 0 || layout.getEllipsisCount(lastLine) <= 1) { + return null + } + // The ellipsis character is the first ellipsized character; the rest of the last line is + // hidden, and so are the paragraphs after it. Text after the ellipsized range (start or + // middle ellipsis) is visible, so keep it. + val ellipsisStart = layout.getLineStart(lastLine) + layout.getEllipsisStart(lastLine) + if (ellipsisStart + layout.getEllipsisCount(lastLine) != layout.getLineEnd(lastLine)) { + return null + } + val end = ellipsisStart + 1 + // Keep the text whole when its hidden part matters beyond drawing: line directions are + // computed over the whole line, and text with right-to-left characters anywhere gets + // bounds-based drawing on Android 15+, which this layout doesn't have; links are exposed to + // accessibility, inline views are measured, replacement spans and text effects draw or work + // on the range they were given, and so do touchable spans. + if ( + TextDirectionHeuristics.ANYRTL_LTR.isRtl(source, 0, source.length) || + source.getSpans(end, source.length, ClickableSpan::class.java).isNotEmpty() || + source.getSpans(end, source.length, ReplacementSpan::class.java).isNotEmpty() || + source.getSpans(end, source.length, TextEffectSpan::class.java).isNotEmpty() || + source.getSpans(end, source.length, TouchableSpan::class.java).isNotEmpty() + ) { + return null + } + // Before Android 15, letter spacing also spaces the hidden characters, and a centered or + // end-aligned line is drawn from a width that includes them. + if ( + layout.alignment != Layout.Alignment.ALIGN_NORMAL && + (layout.paint.letterSpacing != 0f || + source + .getSpans( + end, + layout.getLineEnd(lastLine), + CustomLetterSpacingSpan::class.java, + ) + .isNotEmpty()) + ) { + return null + } + // The layout's text already has the ellipsis character in place. + val trimmedText = layout.text.subSequence(0, end) + if (trimmedText[trimmedText.length - 1] != '\u2026') { + return null + } + return EllipsisTrimmedLayout( + layout, + trimmedText as? SpannableString ?: SpannableString(trimmedText), + ) + } + } + + // --- Abstract methods: delegated, except where the last line ends --- + + override fun getLineCount(): Int = delegate.lineCount + + override fun getLineTop(line: Int): Int = delegate.getLineTop(line) + + override fun getLineDescent(line: Int): Int = delegate.getLineDescent(line) + + override fun getLineStart(line: Int): Int = + if (line > lastLine) text.length else delegate.getLineStart(line) + + override fun getLineContainsTab(line: Int): Boolean = delegate.getLineContainsTab(line) + + override fun getLineDirections(line: Int): Directions = delegate.getLineDirections(line) + + override fun getTopPadding(): Int = delegate.topPadding + + override fun getBottomPadding(): Int = delegate.bottomPadding + + override fun getEllipsisStart(line: Int): Int = delegate.getEllipsisStart(line) + + override fun getEllipsisCount(line: Int): Int = + if (line == lastLine) text.length - getLineStart(line) - getEllipsisStart(line) + else delegate.getEllipsisCount(line) + + override fun getParagraphDirection(line: Int): Int = delegate.getParagraphDirection(line) + + // --- Non-abstract overrides: StaticLayout's fast paths, as in MutableSpannableLayout --- + + override fun getEllipsizedWidth(): Int = delegate.ellipsizedWidth + + override fun getLineMax(line: Int): Float = delegate.getLineMax(line) + + override fun getLineWidth(line: Int): Float = delegate.getLineWidth(line) + + override fun getLineLeft(line: Int): Float = delegate.getLineLeft(line) + + override fun getLineRight(line: Int): Float = delegate.getLineRight(line) + + // Only called by the framework on API 33+ + @android.annotation.SuppressLint("NewApi") + override fun isFallbackLineSpacingEnabled(): Boolean = delegate.isFallbackLineSpacingEnabled +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index ef7c3a33b04e..118c7bd208bf 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -215,7 +215,9 @@ public constructor( preparedLayout: PreparedLayout, ): ReactTextUpdate { val layout = preparedLayout.layout - val text = layout.text + // The TextView lays the text out again, so it needs the whole text, not the drawn text trimmed + // after an ellipsis. + val text = ((layout as? EllipsisTrimmedLayout)?.originalLayout ?: layout).text val spanned = if (text is Spannable) text else SpannableString(text) view.setSpanned(spanned) view.setPreparedLayout(preparedLayout) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index d906e9d78e6b..f8ef3ca47050 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -1355,6 +1355,7 @@ internal object TextLayoutManager { return CreateLayoutResult( layout, textBreakStrategy, + hyphenationFrequency, justificationMode, ) } @@ -1438,8 +1439,23 @@ internal object TextLayoutManager { maximumNumberOfLines, ) + // Prepared layouts are drawn, so drop the text hidden after an ellipsis. Justified lines and + // hyphens are drawn from the justification mode and hyphen edits, which a delegating layout + // doesn't have. + val layout = + if ( + ReactNativeFeatureFlags.enableEllipsizedTextTrimmingAndroid() && + result.hyphenationFrequency == Layout.HYPHENATION_FREQUENCY_NONE && + (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + result.justificationMode == Layout.JUSTIFICATION_MODE_NONE) + ) { + EllipsisTrimmedLayout.createIfNeeded(result.layout, text) ?: result.layout + } else { + result.layout + } + return PreparedLayout( - result.layout, + layout, maximumNumberOfLines, verticalOffset, reactTags, @@ -1960,6 +1976,7 @@ internal object TextLayoutManager { private class CreateLayoutResult( val layout: Layout, val textBreakStrategy: Int, + val hyphenationFrequency: Int, val justificationMode: Int, ) diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp index 23fd0978a766..69256891638a 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp @@ -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<<944781e7a547e601f0637869a3a3ec5e>> + * @generated SignedSource<<7500b475cd644b37a53a4bc9df89494a>> */ /** @@ -183,6 +183,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool enableEllipsizedTextTrimmingAndroid() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enableEllipsizedTextTrimmingAndroid"); + return method(javaProvider_); + } + bool enableExclusivePropsUpdateAndroid() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enableExclusivePropsUpdateAndroid"); @@ -745,6 +751,11 @@ bool JReactNativeFeatureFlagsCxxInterop::enableEagerRootViewAttachment( return ReactNativeFeatureFlags::enableEagerRootViewAttachment(); } +bool JReactNativeFeatureFlagsCxxInterop::enableEllipsizedTextTrimmingAndroid( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::enableEllipsizedTextTrimmingAndroid(); +} + bool JReactNativeFeatureFlagsCxxInterop::enableExclusivePropsUpdateAndroid( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::enableExclusivePropsUpdateAndroid(); @@ -1213,6 +1224,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "enableEagerRootViewAttachment", JReactNativeFeatureFlagsCxxInterop::enableEagerRootViewAttachment), + makeNativeMethod( + "enableEllipsizedTextTrimmingAndroid", + JReactNativeFeatureFlagsCxxInterop::enableEllipsizedTextTrimmingAndroid), makeNativeMethod( "enableExclusivePropsUpdateAndroid", JReactNativeFeatureFlagsCxxInterop::enableExclusivePropsUpdateAndroid), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index ae774f80c82b..ebda1864febd 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -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<> + * @generated SignedSource<<046411b1c1b422854597ec5c0cfadcd1>> */ /** @@ -102,6 +102,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool enableEagerRootViewAttachment( facebook::jni::alias_ref); + static bool enableEllipsizedTextTrimmingAndroid( + facebook::jni::alias_ref); + static bool enableExclusivePropsUpdateAndroid( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/EllipsisTrimmedLayoutTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/EllipsisTrimmedLayoutTest.kt new file mode 100644 index 000000000000..9f64d4bda1b2 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/EllipsisTrimmedLayoutTest.kt @@ -0,0 +1,335 @@ +/* + * 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. + */ + +@file:OptIn(UnstableReactNativeAPI::class) + +package com.facebook.react.views.text + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.text.LineBreakConfig +import android.text.Layout +import android.text.Spannable +import android.text.SpannableString +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.ReplacementSpan +import android.text.style.StyleSpan +import android.view.View +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan +import com.facebook.react.views.text.internal.span.TextEffectSpan +import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan +import com.facebook.react.views.text.internal.span.TouchableSpan +import com.facebook.yoga.YogaMeasureMode +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 +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@SuppressLint("NewApi") +@Config(sdk = [28, 30, 34, 35]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class EllipsisTrimmedLayoutTest { + + private val longText = (1..40).joinToString(" ") { "word$it" } + + @Test + fun `one ellipsized line keeps only the text up to the ellipsis`() { + val text = SpannableString(longText) + val layout = createLayout(text, maxLines = 1) + + val trimmed = checkNotNull(EllipsisTrimmedLayout.createIfNeeded(layout, text)) + + assertThat(trimmed.text.length).isEqualTo(layout.getEllipsisStart(0) + 1) + assertThat(trimmed.text[trimmed.text.length - 1]).isEqualTo('…') + assertThat(trimmed.originalLayout).isSameAs(layout) + assertSameLayout(trimmed, layout) + } + + @Test + fun `several ellipsized lines with styled text crossing the ellipsis draw the same`() { + val text = SpannableString(longText) + text.setSpan(StyleSpan(Typeface.BOLD), 20, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + text.setSpan( + ForegroundColorSpan(Color.RED), + 40, + text.length - 10, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val layout = createLayout(text, maxLines = 3) + + val trimmed = checkNotNull(EllipsisTrimmedLayout.createIfNeeded(layout, text)) + + assertThat(trimmed.text.length).isLessThan(text.length) + assertSameLayout(trimmed, layout) + } + + @Test + fun `hidden text with line breaks draws the same`() { + val text = SpannableString(longText + "\n" + longText + "\n" + longText) + val layout = createLayout(text, maxLines = 2) + + val trimmed = checkNotNull(EllipsisTrimmedLayout.createIfNeeded(layout, text)) + + assertThat(trimmed.text.length).isLessThan(text.length) + assertSameLayout(trimmed, layout) + } + + @Test + fun `centered and end-aligned text draws the same`() { + for (alignment in listOf(Layout.Alignment.ALIGN_CENTER, Layout.Alignment.ALIGN_OPPOSITE)) { + val text = SpannableString(longText) + val layout = createLayout(text, maxLines = 2, alignment = alignment) + + val trimmed = checkNotNull(EllipsisTrimmedLayout.createIfNeeded(layout, text)) + + assertSameLayout(trimmed, layout) + } + } + + @Test + fun `text with letter spacing draws the same`() { + val text = SpannableString(longText) + text.setSpan( + CustomLetterSpacingSpan(0.1f), + 0, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val layout = createLayout(text, maxLines = 2, textPaint = spacedPaint) + + val trimmed = checkNotNull(EllipsisTrimmedLayout.createIfNeeded(layout, text)) + + assertSameLayout(trimmed, layout) + } + + @Test + fun `centered text with letter spacing in its hidden text is kept`() { + val spanned = SpannableString(longText) + spanned.setSpan( + CustomLetterSpacingSpan(0.1f), + spanned.length - 5, + spanned.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val spannedLayout = + createLayout(spanned, maxLines = 2, alignment = Layout.Alignment.ALIGN_CENTER) + assertThat(EllipsisTrimmedLayout.createIfNeeded(spannedLayout, spanned)).isNull() + + val text = SpannableString(longText) + val layout = + createLayout( + text, + maxLines = 2, + alignment = Layout.Alignment.ALIGN_CENTER, + textPaint = spacedPaint, + ) + assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + } + + @Test + fun `text that is not ellipsized is kept`() { + val text = SpannableString("word1 word2") + val layout = createLayout(text, maxLines = 1) + + assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + } + + @Test + fun `text whose ellipsis hides nothing else is kept`() { + val text = SpannableString("word1\nword2") + val layout = createLayout(text, maxLines = 1) + + assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + } + + @Test + fun `text ellipsized in the middle is kept`() { + val text = SpannableString(longText) + val layout = createLayout(text, maxLines = 1, ellipsizeMode = TextUtils.TruncateAt.MIDDLE) + + assertThat(layout.getEllipsisCount(0)).isGreaterThan(1) + assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + } + + @Test + fun `right-to-left text is kept`() { + val text = SpannableString((1..40).joinToString(" ") { "שלום$it" }) + val layout = createLayout(text, maxLines = 1) + + assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + } + + @Test + fun `right-to-left characters only in the hidden text keep the whole text`() { + // Android 15+ draws text with right-to-left characters anywhere with bounds-based width. + val text = SpannableString("$longText\n${(1..10).joinToString(" ") { "שלום$it" }}") + val layout = createLayout(text, maxLines = 1) + + assertThat(layout.getEllipsisCount(0)).isGreaterThan(1) + assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + } + + @Test + fun `a link in the visible text is kept and one in the hidden text keeps the whole text`() { + val visible = SpannableString(longText) + visible.setSpan(ClickableLink(), 0, 5, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val trimmed = + checkNotNull( + EllipsisTrimmedLayout.createIfNeeded(createLayout(visible, maxLines = 1), visible) + ) + val trimmedText = trimmed.text as Spannable + assertThat(trimmedText.getSpans(0, trimmedText.length, ClickableLink::class.java)).hasSize(1) + + assertHiddenSpanKeepsText(ClickableLink()) + + // A link that starts in the visible text and continues into the hidden text. + val crossing = SpannableString(longText) + crossing.setSpan(ClickableLink(), 5, crossing.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + assertThat(EllipsisTrimmedLayout.createIfNeeded(createLayout(crossing, maxLines = 1), crossing)) + .isNull() + } + + @Test + fun `an inline view in the hidden text keeps the whole text`() { + assertHiddenSpanKeepsText( + TextInlineViewPlaceholderSpan(/* reactTag= */ 1, /* width= */ 10, /* height= */ 10) + ) + } + + @Test + fun `a replacement span in the hidden text keeps the whole text`() { + assertHiddenSpanKeepsText( + object : ReplacementSpan() { + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt?, + ): Int = 10 + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint, + ) = Unit + } + ) + } + + @Test + fun `a text effect in the hidden text keeps the whole text`() { + assertHiddenSpanKeepsText(object : TextEffectSpan {}) + } + + @Test + fun `a touchable span in the hidden text keeps the whole text`() { + assertHiddenSpanKeepsText( + object : TouchableSpan { + override fun onTouchEvent(action: Int, layoutX: Float, layoutY: Float): Boolean = false + } + ) + } + + private fun assertHiddenSpanKeepsText(span: Any) { + val text = SpannableString(longText) + text.setSpan(span, text.length - 1, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val layout = createLayout(text, maxLines = 1) + + assertThat(layout.getEllipsisCount(0)).isGreaterThan(1) + assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + } + + private fun assertSameLayout(trimmed: Layout, layout: Layout) { + assertThat(trimmed.lineCount).isEqualTo(layout.lineCount) + assertThat(trimmed.height).isEqualTo(layout.height) + for (line in 0 until layout.lineCount) { + assertThat(trimmed.getLineStart(line)).isEqualTo(layout.getLineStart(line)) + assertThat(trimmed.getEllipsisStart(line)).isEqualTo(layout.getEllipsisStart(line)) + assertThat(trimmed.getLineBaseline(line)).isEqualTo(layout.getLineBaseline(line)) + assertThat(trimmed.getLineWidth(line)).isEqualTo(layout.getLineWidth(line)) + } + val lastLine = layout.lineCount - 1 + assertThat(trimmed.getLineEnd(lastLine)).isEqualTo(trimmed.text.length) + // Touches on the last line, including on the ellipsis, land on the same characters. + val lineLeft = layout.getLineLeft(lastLine).toInt() + val lineRight = layout.getLineRight(lastLine).toInt() + for (x in lineLeft..lineRight step 5) { + assertThat(trimmed.getOffsetForHorizontal(lastLine, x.toFloat())) + .isEqualTo( + minOf(layout.getOffsetForHorizontal(lastLine, x.toFloat()), trimmed.text.length) + ) + } + val drawn = draw(layout) + assertThat(drawn.sameAs(draw(trimmed))).isTrue() + // Make sure that text was drawn at all. + assertThat( + (0 until drawn.height).any { y -> + (0 until drawn.width).any { x -> drawn.getPixel(x, y) != Color.WHITE } + } + ) + .isTrue() + } + + private fun draw(layout: Layout): Bitmap { + val bitmap = Bitmap.createBitmap(layout.width, layout.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.WHITE) + layout.draw(canvas) + return bitmap + } + + private fun createLayout( + text: Spannable, + maxLines: Int, + ellipsizeMode: TextUtils.TruncateAt = TextUtils.TruncateAt.END, + alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + textPaint: TextPaint = paint, + ): Layout = + TextLayoutManager.createLayout( + text, + /* boring = */ null, + /* width = */ 400f, + YogaMeasureMode.EXACTLY, + /* 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, + alignment, + Layout.JUSTIFICATION_MODE_NONE, + ellipsizeMode, + maxLines, + textPaint, + ) + + private class ClickableLink : ClickableSpan() { + override fun onClick(widget: View) = Unit + } + + private companion object { + val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f } + val spacedPaint = TextPaint(paint).apply { letterSpacing = 0.1f } + } +} diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index ac8ddb96a112..b5ce26da3a47 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -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<<56e127471f63e47798ec619e6fa56738>> + * @generated SignedSource<<1cd242f72f95aaf95ea05fa25aea7cdf>> */ /** @@ -122,6 +122,10 @@ bool ReactNativeFeatureFlags::enableEagerRootViewAttachment() { return getAccessor().enableEagerRootViewAttachment(); } +bool ReactNativeFeatureFlags::enableEllipsizedTextTrimmingAndroid() { + return getAccessor().enableEllipsizedTextTrimmingAndroid(); +} + bool ReactNativeFeatureFlags::enableExclusivePropsUpdateAndroid() { return getAccessor().enableExclusivePropsUpdateAndroid(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index 0e5b3f2013fb..835a6fa5ac25 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -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<<6887b671526bc8a3b0678e3e45b4e3b4>> + * @generated SignedSource<> */ /** @@ -161,6 +161,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool enableEagerRootViewAttachment(); + /** + * Draws ellipsized text on Android without the text hidden after the ellipsis, so that it is not shaped again on every draw. + */ + RN_EXPORT static bool enableEllipsizedTextTrimmingAndroid(); + /** * When enabled, Android will disable Props 1.5 raw value merging when Props 2.0 is available. */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index 7640cea6418a..52e6b49dd715 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -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<<5d9712bd7e62ba3a29b084ba4c60bec5>> + * @generated SignedSource<> */ /** @@ -461,6 +461,24 @@ bool ReactNativeFeatureFlagsAccessor::enableEagerRootViewAttachment() { return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::enableEllipsizedTextTrimmingAndroid() { + auto flagValue = enableEllipsizedTextTrimmingAndroid_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(24, "enableEllipsizedTextTrimmingAndroid"); + + flagValue = currentProvider_->enableEllipsizedTextTrimmingAndroid(); + enableEllipsizedTextTrimmingAndroid_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::enableExclusivePropsUpdateAndroid() { auto flagValue = enableExclusivePropsUpdateAndroid_.load(); @@ -470,7 +488,7 @@ bool ReactNativeFeatureFlagsAccessor::enableExclusivePropsUpdateAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(24, "enableExclusivePropsUpdateAndroid"); + markFlagAsAccessed(25, "enableExclusivePropsUpdateAndroid"); flagValue = currentProvider_->enableExclusivePropsUpdateAndroid(); enableExclusivePropsUpdateAndroid_ = flagValue; @@ -488,7 +506,7 @@ bool ReactNativeFeatureFlagsAccessor::enableFabricCommitBranching() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(25, "enableFabricCommitBranching"); + markFlagAsAccessed(26, "enableFabricCommitBranching"); flagValue = currentProvider_->enableFabricCommitBranching(); enableFabricCommitBranching_ = flagValue; @@ -506,7 +524,7 @@ bool ReactNativeFeatureFlagsAccessor::enableFabricCommitBranchingMergeOnMainThre // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(26, "enableFabricCommitBranchingMergeOnMainThread"); + markFlagAsAccessed(27, "enableFabricCommitBranchingMergeOnMainThread"); flagValue = currentProvider_->enableFabricCommitBranchingMergeOnMainThread(); enableFabricCommitBranchingMergeOnMainThread_ = flagValue; @@ -524,7 +542,7 @@ bool ReactNativeFeatureFlagsAccessor::enableFabricLogs() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(27, "enableFabricLogs"); + markFlagAsAccessed(28, "enableFabricLogs"); flagValue = currentProvider_->enableFabricLogs(); enableFabricLogs_ = flagValue; @@ -542,7 +560,7 @@ bool ReactNativeFeatureFlagsAccessor::enableFlexboxAutoMinSizeInStrictMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(28, "enableFlexboxAutoMinSizeInStrictMode"); + markFlagAsAccessed(29, "enableFlexboxAutoMinSizeInStrictMode"); flagValue = currentProvider_->enableFlexboxAutoMinSizeInStrictMode(); enableFlexboxAutoMinSizeInStrictMode_ = flagValue; @@ -560,7 +578,7 @@ bool ReactNativeFeatureFlagsAccessor::enableFollowUpMountItemDispatchAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(29, "enableFollowUpMountItemDispatchAndroid"); + markFlagAsAccessed(30, "enableFollowUpMountItemDispatchAndroid"); flagValue = currentProvider_->enableFollowUpMountItemDispatchAndroid(); enableFollowUpMountItemDispatchAndroid_ = flagValue; @@ -578,7 +596,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSCompressedTextFrameAdjustment() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(30, "enableIOSCompressedTextFrameAdjustment"); + markFlagAsAccessed(31, "enableIOSCompressedTextFrameAdjustment"); flagValue = currentProvider_->enableIOSCompressedTextFrameAdjustment(); enableIOSCompressedTextFrameAdjustment_ = flagValue; @@ -596,7 +614,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSTextBaselineOffsetPerLine() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(31, "enableIOSTextBaselineOffsetPerLine"); + markFlagAsAccessed(32, "enableIOSTextBaselineOffsetPerLine"); flagValue = currentProvider_->enableIOSTextBaselineOffsetPerLine(); enableIOSTextBaselineOffsetPerLine_ = flagValue; @@ -614,7 +632,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSViewClipToPaddingBox() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(32, "enableIOSViewClipToPaddingBox"); + markFlagAsAccessed(33, "enableIOSViewClipToPaddingBox"); flagValue = currentProvider_->enableIOSViewClipToPaddingBox(); enableIOSViewClipToPaddingBox_ = flagValue; @@ -632,7 +650,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImagePrefetchingAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(33, "enableImagePrefetchingAndroid"); + markFlagAsAccessed(34, "enableImagePrefetchingAndroid"); flagValue = currentProvider_->enableImagePrefetchingAndroid(); enableImagePrefetchingAndroid_ = flagValue; @@ -650,7 +668,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImageTransparentTintColor() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(34, "enableImageTransparentTintColor"); + markFlagAsAccessed(35, "enableImageTransparentTintColor"); flagValue = currentProvider_->enableImageTransparentTintColor(); enableImageTransparentTintColor_ = flagValue; @@ -668,7 +686,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImmediateUpdateModeForContentOffsetC // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(35, "enableImmediateUpdateModeForContentOffsetChanges"); + markFlagAsAccessed(36, "enableImmediateUpdateModeForContentOffsetChanges"); flagValue = currentProvider_->enableImmediateUpdateModeForContentOffsetChanges(); enableImmediateUpdateModeForContentOffsetChanges_ = flagValue; @@ -686,7 +704,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImperativeEvents() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(36, "enableImperativeEvents"); + markFlagAsAccessed(37, "enableImperativeEvents"); flagValue = currentProvider_->enableImperativeEvents(); enableImperativeEvents_ = flagValue; @@ -704,7 +722,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImperativeFocus() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(37, "enableImperativeFocus"); + markFlagAsAccessed(38, "enableImperativeFocus"); flagValue = currentProvider_->enableImperativeFocus(); enableImperativeFocus_ = flagValue; @@ -722,7 +740,7 @@ bool ReactNativeFeatureFlagsAccessor::enableInteropViewManagerClassLookUpOptimiz // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(38, "enableInteropViewManagerClassLookUpOptimizationIOS"); + markFlagAsAccessed(39, "enableInteropViewManagerClassLookUpOptimizationIOS"); flagValue = currentProvider_->enableInteropViewManagerClassLookUpOptimizationIOS(); enableInteropViewManagerClassLookUpOptimizationIOS_ = flagValue; @@ -740,7 +758,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIntersectionObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(39, "enableIntersectionObserverByDefault"); + markFlagAsAccessed(40, "enableIntersectionObserverByDefault"); flagValue = currentProvider_->enableIntersectionObserverByDefault(); enableIntersectionObserverByDefault_ = flagValue; @@ -758,7 +776,7 @@ bool ReactNativeFeatureFlagsAccessor::enableKeyEvents() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(40, "enableKeyEvents"); + markFlagAsAccessed(41, "enableKeyEvents"); flagValue = currentProvider_->enableKeyEvents(); enableKeyEvents_ = flagValue; @@ -776,7 +794,7 @@ bool ReactNativeFeatureFlagsAccessor::enableLayoutAnimationsOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(41, "enableLayoutAnimationsOnAndroid"); + markFlagAsAccessed(42, "enableLayoutAnimationsOnAndroid"); flagValue = currentProvider_->enableLayoutAnimationsOnAndroid(); enableLayoutAnimationsOnAndroid_ = flagValue; @@ -794,7 +812,7 @@ bool ReactNativeFeatureFlagsAccessor::enableLayoutAnimationsOnIOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(42, "enableLayoutAnimationsOnIOS"); + markFlagAsAccessed(43, "enableLayoutAnimationsOnIOS"); flagValue = currentProvider_->enableLayoutAnimationsOnIOS(); enableLayoutAnimationsOnIOS_ = flagValue; @@ -812,7 +830,7 @@ bool ReactNativeFeatureFlagsAccessor::enableModuleArgumentNSNullConversionIOS() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(43, "enableModuleArgumentNSNullConversionIOS"); + markFlagAsAccessed(44, "enableModuleArgumentNSNullConversionIOS"); flagValue = currentProvider_->enableModuleArgumentNSNullConversionIOS(); enableModuleArgumentNSNullConversionIOS_ = flagValue; @@ -830,7 +848,7 @@ bool ReactNativeFeatureFlagsAccessor::enableMountingCoordinatorPullModelAndroid( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(44, "enableMountingCoordinatorPullModelAndroid"); + markFlagAsAccessed(45, "enableMountingCoordinatorPullModelAndroid"); flagValue = currentProvider_->enableMountingCoordinatorPullModelAndroid(); enableMountingCoordinatorPullModelAndroid_ = flagValue; @@ -848,7 +866,7 @@ bool ReactNativeFeatureFlagsAccessor::enableMutationObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(45, "enableMutationObserverByDefault"); + markFlagAsAccessed(46, "enableMutationObserverByDefault"); flagValue = currentProvider_->enableMutationObserverByDefault(); enableMutationObserverByDefault_ = flagValue; @@ -866,7 +884,7 @@ bool ReactNativeFeatureFlagsAccessor::enableNativeCSSParsing() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(46, "enableNativeCSSParsing"); + markFlagAsAccessed(47, "enableNativeCSSParsing"); flagValue = currentProvider_->enableNativeCSSParsing(); enableNativeCSSParsing_ = flagValue; @@ -884,7 +902,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePreallocatedPropsDiffOnInsertAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(47, "enablePreallocatedPropsDiffOnInsertAndroid"); + markFlagAsAccessed(48, "enablePreallocatedPropsDiffOnInsertAndroid"); flagValue = currentProvider_->enablePreallocatedPropsDiffOnInsertAndroid(); enablePreallocatedPropsDiffOnInsertAndroid_ = flagValue; @@ -902,7 +920,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePreparedTextLayout() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(48, "enablePreparedTextLayout"); + markFlagAsAccessed(49, "enablePreparedTextLayout"); flagValue = currentProvider_->enablePreparedTextLayout(); enablePreparedTextLayout_ = flagValue; @@ -920,7 +938,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePropsUpdateReconciliationAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(49, "enablePropsUpdateReconciliationAndroid"); + markFlagAsAccessed(50, "enablePropsUpdateReconciliationAndroid"); flagValue = currentProvider_->enablePropsUpdateReconciliationAndroid(); enablePropsUpdateReconciliationAndroid_ = flagValue; @@ -938,7 +956,7 @@ bool ReactNativeFeatureFlagsAccessor::enableResizeObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(50, "enableResizeObserverByDefault"); + markFlagAsAccessed(51, "enableResizeObserverByDefault"); flagValue = currentProvider_->enableResizeObserverByDefault(); enableResizeObserverByDefault_ = flagValue; @@ -956,7 +974,7 @@ bool ReactNativeFeatureFlagsAccessor::enableSwiftUIBasedFilters() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(51, "enableSwiftUIBasedFilters"); + markFlagAsAccessed(52, "enableSwiftUIBasedFilters"); flagValue = currentProvider_->enableSwiftUIBasedFilters(); enableSwiftUIBasedFilters_ = flagValue; @@ -974,7 +992,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewCulling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(52, "enableViewCulling"); + markFlagAsAccessed(53, "enableViewCulling"); flagValue = currentProvider_->enableViewCulling(); enableViewCulling_ = flagValue; @@ -992,7 +1010,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecycling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(53, "enableViewRecycling"); + markFlagAsAccessed(54, "enableViewRecycling"); flagValue = currentProvider_->enableViewRecycling(); enableViewRecycling_ = flagValue; @@ -1010,7 +1028,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForImage() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(54, "enableViewRecyclingForImage"); + markFlagAsAccessed(55, "enableViewRecyclingForImage"); flagValue = currentProvider_->enableViewRecyclingForImage(); enableViewRecyclingForImage_ = flagValue; @@ -1028,7 +1046,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForScrollView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(55, "enableViewRecyclingForScrollView"); + markFlagAsAccessed(56, "enableViewRecyclingForScrollView"); flagValue = currentProvider_->enableViewRecyclingForScrollView(); enableViewRecyclingForScrollView_ = flagValue; @@ -1046,7 +1064,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForText() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(56, "enableViewRecyclingForText"); + markFlagAsAccessed(57, "enableViewRecyclingForText"); flagValue = currentProvider_->enableViewRecyclingForText(); enableViewRecyclingForText_ = flagValue; @@ -1064,7 +1082,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(57, "enableViewRecyclingForView"); + markFlagAsAccessed(58, "enableViewRecyclingForView"); flagValue = currentProvider_->enableViewRecyclingForView(); enableViewRecyclingForView_ = flagValue; @@ -1082,7 +1100,7 @@ bool ReactNativeFeatureFlagsAccessor::enableVirtualViewContainerStateExperimenta // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(58, "enableVirtualViewContainerStateExperimental"); + markFlagAsAccessed(59, "enableVirtualViewContainerStateExperimental"); flagValue = currentProvider_->enableVirtualViewContainerStateExperimental(); enableVirtualViewContainerStateExperimental_ = flagValue; @@ -1100,7 +1118,7 @@ bool ReactNativeFeatureFlagsAccessor::fixBorderlessRippleAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(59, "fixBorderlessRippleAndroid"); + markFlagAsAccessed(60, "fixBorderlessRippleAndroid"); flagValue = currentProvider_->fixBorderlessRippleAndroid(); fixBorderlessRippleAndroid_ = flagValue; @@ -1118,7 +1136,7 @@ bool ReactNativeFeatureFlagsAccessor::fixMappingOfEventPrioritiesBetweenFabricAn // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(60, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); + markFlagAsAccessed(61, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); flagValue = currentProvider_->fixMappingOfEventPrioritiesBetweenFabricAndReact(); fixMappingOfEventPrioritiesBetweenFabricAndReact_ = flagValue; @@ -1136,7 +1154,7 @@ bool ReactNativeFeatureFlagsAccessor::fixTextLayoutOnPixelDensityChange() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(61, "fixTextLayoutOnPixelDensityChange"); + markFlagAsAccessed(62, "fixTextLayoutOnPixelDensityChange"); flagValue = currentProvider_->fixTextLayoutOnPixelDensityChange(); fixTextLayoutOnPixelDensityChange_ = flagValue; @@ -1154,7 +1172,7 @@ bool ReactNativeFeatureFlagsAccessor::fixYogaFlexBasisFitContentInMainAxis() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(62, "fixYogaFlexBasisFitContentInMainAxis"); + markFlagAsAccessed(63, "fixYogaFlexBasisFitContentInMainAxis"); flagValue = currentProvider_->fixYogaFlexBasisFitContentInMainAxis(); fixYogaFlexBasisFitContentInMainAxis_ = flagValue; @@ -1172,7 +1190,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxAssertSingleHostState() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(63, "fuseboxAssertSingleHostState"); + markFlagAsAccessed(64, "fuseboxAssertSingleHostState"); flagValue = currentProvider_->fuseboxAssertSingleHostState(); fuseboxAssertSingleHostState_ = flagValue; @@ -1190,7 +1208,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxEnabledRelease() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(64, "fuseboxEnabledRelease"); + markFlagAsAccessed(65, "fuseboxEnabledRelease"); flagValue = currentProvider_->fuseboxEnabledRelease(); fuseboxEnabledRelease_ = flagValue; @@ -1208,7 +1226,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxFrameRecordingEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(65, "fuseboxFrameRecordingEnabled"); + markFlagAsAccessed(66, "fuseboxFrameRecordingEnabled"); flagValue = currentProvider_->fuseboxFrameRecordingEnabled(); fuseboxFrameRecordingEnabled_ = flagValue; @@ -1226,7 +1244,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxScreenshotCaptureEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(66, "fuseboxScreenshotCaptureEnabled"); + markFlagAsAccessed(67, "fuseboxScreenshotCaptureEnabled"); flagValue = currentProvider_->fuseboxScreenshotCaptureEnabled(); fuseboxScreenshotCaptureEnabled_ = flagValue; @@ -1244,7 +1262,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxWebSocketEventsEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(67, "fuseboxWebSocketEventsEnabled"); + markFlagAsAccessed(68, "fuseboxWebSocketEventsEnabled"); flagValue = currentProvider_->fuseboxWebSocketEventsEnabled(); fuseboxWebSocketEventsEnabled_ = flagValue; @@ -1262,7 +1280,7 @@ bool ReactNativeFeatureFlagsAccessor::keepTimersRunningWhileHostPausedAndroid() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(68, "keepTimersRunningWhileHostPausedAndroid"); + markFlagAsAccessed(69, "keepTimersRunningWhileHostPausedAndroid"); flagValue = currentProvider_->keepTimersRunningWhileHostPausedAndroid(); keepTimersRunningWhileHostPausedAndroid_ = flagValue; @@ -1280,7 +1298,7 @@ bool ReactNativeFeatureFlagsAccessor::optimizedAnimatedPropUpdates() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(69, "optimizedAnimatedPropUpdates"); + markFlagAsAccessed(70, "optimizedAnimatedPropUpdates"); flagValue = currentProvider_->optimizedAnimatedPropUpdates(); optimizedAnimatedPropUpdates_ = flagValue; @@ -1298,7 +1316,7 @@ bool ReactNativeFeatureFlagsAccessor::overrideBySynchronousMountPropsAtMountingA // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(70, "overrideBySynchronousMountPropsAtMountingAndroid"); + markFlagAsAccessed(71, "overrideBySynchronousMountPropsAtMountingAndroid"); flagValue = currentProvider_->overrideBySynchronousMountPropsAtMountingAndroid(); overrideBySynchronousMountPropsAtMountingAndroid_ = flagValue; @@ -1316,7 +1334,7 @@ bool ReactNativeFeatureFlagsAccessor::perfIssuesEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(71, "perfIssuesEnabled"); + markFlagAsAccessed(72, "perfIssuesEnabled"); flagValue = currentProvider_->perfIssuesEnabled(); perfIssuesEnabled_ = flagValue; @@ -1334,7 +1352,7 @@ bool ReactNativeFeatureFlagsAccessor::perfMonitorV2Enabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(72, "perfMonitorV2Enabled"); + markFlagAsAccessed(73, "perfMonitorV2Enabled"); flagValue = currentProvider_->perfMonitorV2Enabled(); perfMonitorV2Enabled_ = flagValue; @@ -1352,7 +1370,7 @@ double ReactNativeFeatureFlagsAccessor::preparedTextCacheSize() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(73, "preparedTextCacheSize"); + markFlagAsAccessed(74, "preparedTextCacheSize"); flagValue = currentProvider_->preparedTextCacheSize(); preparedTextCacheSize_ = flagValue; @@ -1370,7 +1388,7 @@ bool ReactNativeFeatureFlagsAccessor::preventShadowTreeCommitExhaustion() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(74, "preventShadowTreeCommitExhaustion"); + markFlagAsAccessed(75, "preventShadowTreeCommitExhaustion"); flagValue = currentProvider_->preventShadowTreeCommitExhaustion(); preventShadowTreeCommitExhaustion_ = flagValue; @@ -1388,7 +1406,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2Android() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(75, "redBoxV2Android"); + markFlagAsAccessed(76, "redBoxV2Android"); flagValue = currentProvider_->redBoxV2Android(); redBoxV2Android_ = flagValue; @@ -1406,7 +1424,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2IOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(76, "redBoxV2IOS"); + markFlagAsAccessed(77, "redBoxV2IOS"); flagValue = currentProvider_->redBoxV2IOS(); redBoxV2IOS_ = flagValue; @@ -1424,7 +1442,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldPressibilityUseW3CPointerEventsForHo // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(77, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(78, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1442,7 +1460,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldTriggerResponderTransferOnScrollAndr // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(78, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(79, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1460,7 +1478,7 @@ bool ReactNativeFeatureFlagsAccessor::skipActivityIdentityAssertionOnHostPause() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(79, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(80, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1478,7 +1496,7 @@ bool ReactNativeFeatureFlagsAccessor::skipUnchangedChildrenInDifferentiator() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(80, "skipUnchangedChildrenInDifferentiator"); + markFlagAsAccessed(81, "skipUnchangedChildrenInDifferentiator"); flagValue = currentProvider_->skipUnchangedChildrenInDifferentiator(); skipUnchangedChildrenInDifferentiator_ = flagValue; @@ -1496,7 +1514,7 @@ bool ReactNativeFeatureFlagsAccessor::syncAndroidClipBoundsWithOverflow() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(81, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(82, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1514,7 +1532,7 @@ bool ReactNativeFeatureFlagsAccessor::traceTurboModulePromiseRejectionsOnAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(82, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(83, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1532,7 +1550,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommit( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(83, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(84, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1550,7 +1568,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommitT // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(84, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(85, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1568,7 +1586,7 @@ bool ReactNativeFeatureFlagsAccessor::useAlwaysAvailableJSErrorHandling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(86, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1586,7 +1604,7 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(86, "useFabricInterop"); + markFlagAsAccessed(87, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1604,7 +1622,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(87, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(88, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1622,7 +1640,7 @@ bool ReactNativeFeatureFlagsAccessor::useNestedScrollViewAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(88, "useNestedScrollViewAndroid"); + markFlagAsAccessed(89, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1640,7 +1658,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(89, "useSharedAnimatedBackend"); + markFlagAsAccessed(90, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1658,7 +1676,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(90, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(91, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1676,7 +1694,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(91, "useTurboModuleInterop"); + markFlagAsAccessed(92, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1694,7 +1712,7 @@ bool ReactNativeFeatureFlagsAccessor::useWindowDensityForDisplayMetricsAndroid() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(92, "useWindowDensityForDisplayMetricsAndroid"); + markFlagAsAccessed(93, "useWindowDensityForDisplayMetricsAndroid"); flagValue = currentProvider_->useWindowDensityForDisplayMetricsAndroid(); useWindowDensityForDisplayMetricsAndroid_ = flagValue; @@ -1712,7 +1730,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(93, "viewCullingOutsetRatio"); + markFlagAsAccessed(94, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1730,7 +1748,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(94, "viewTransitionEnabled"); + markFlagAsAccessed(95, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1748,7 +1766,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionUseHardwareBitmapAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(95, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(96, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1766,7 +1784,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(96, "virtualViewPrerenderRatio"); + markFlagAsAccessed(97, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index 7a9bc59bc57e..29aed2380cb2 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -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<<17f41335dce7e5b45e7af2878c47d69b>> + * @generated SignedSource<<2d2cbf8b524ee0a120a88f070c09f96f>> */ /** @@ -58,6 +58,7 @@ class ReactNativeFeatureFlagsAccessor { bool enableDestroyShadowTreeRevisionAsync(); bool enableDoubleMeasurementFixAndroid(); bool enableEagerRootViewAttachment(); + bool enableEllipsizedTextTrimmingAndroid(); bool enableExclusivePropsUpdateAndroid(); bool enableFabricCommitBranching(); bool enableFabricCommitBranchingMergeOnMainThread(); @@ -142,7 +143,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 97> accessedFeatureFlags_; + std::array, 98> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -168,6 +169,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> enableDestroyShadowTreeRevisionAsync_; std::atomic> enableDoubleMeasurementFixAndroid_; std::atomic> enableEagerRootViewAttachment_; + std::atomic> enableEllipsizedTextTrimmingAndroid_; std::atomic> enableExclusivePropsUpdateAndroid_; std::atomic> enableFabricCommitBranching_; std::atomic> enableFabricCommitBranchingMergeOnMainThread_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index 600aab1882cc..540d221df2f6 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -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<<35e8175277baa31d135d802ef5a5e6f1>> + * @generated SignedSource<> */ /** @@ -125,6 +125,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool enableEllipsizedTextTrimmingAndroid() override { + return false; + } + bool enableExclusivePropsUpdateAndroid() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 88878807cfec..3459e9309557 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -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<> + * @generated SignedSource<<6f8a48ccddbba13d212565740f017f24>> */ /** @@ -263,6 +263,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::enableEagerRootViewAttachment(); } + bool enableEllipsizedTextTrimmingAndroid() override { + auto value = values_["enableEllipsizedTextTrimmingAndroid"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::enableEllipsizedTextTrimmingAndroid(); + } + bool enableExclusivePropsUpdateAndroid() override { auto value = values_["enableExclusivePropsUpdateAndroid"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index 664922287009..aea7d13bf94f 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -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<<7413e958441d0502dc1266ff87e9da85>> + * @generated SignedSource<> */ /** @@ -51,6 +51,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool enableDestroyShadowTreeRevisionAsync() = 0; virtual bool enableDoubleMeasurementFixAndroid() = 0; virtual bool enableEagerRootViewAttachment() = 0; + virtual bool enableEllipsizedTextTrimmingAndroid() = 0; virtual bool enableExclusivePropsUpdateAndroid() = 0; virtual bool enableFabricCommitBranching() = 0; virtual bool enableFabricCommitBranchingMergeOnMainThread() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index ca8a80f1adbf..dfbfaa81dda5 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -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<> + * @generated SignedSource<> */ /** @@ -164,6 +164,11 @@ bool NativeReactNativeFeatureFlags::enableEagerRootViewAttachment( return ReactNativeFeatureFlags::enableEagerRootViewAttachment(); } +bool NativeReactNativeFeatureFlags::enableEllipsizedTextTrimmingAndroid( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::enableEllipsizedTextTrimmingAndroid(); +} + bool NativeReactNativeFeatureFlags::enableExclusivePropsUpdateAndroid( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::enableExclusivePropsUpdateAndroid(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index 7413766e285a..59116b8ada09 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -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<<90f271ea47f91e6a84979209b7b3d816>> + * @generated SignedSource<<2827752b1ae114bb5626dec944437ccf>> */ /** @@ -86,6 +86,8 @@ class NativeReactNativeFeatureFlags bool enableEagerRootViewAttachment(jsi::Runtime& runtime); + bool enableEllipsizedTextTrimmingAndroid(jsi::Runtime& runtime); + bool enableExclusivePropsUpdateAndroid(jsi::Runtime& runtime); bool enableFabricCommitBranching(jsi::Runtime& runtime); diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 612c196ff22d..bb9012396b8a 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -311,6 +311,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enableEllipsizedTextTrimmingAndroid: { + defaultValue: false, + metadata: { + dateAdded: '2026-10-29', + description: + 'Draws ellipsized text on Android without the text hidden after the ellipsis, so that it is not shaped again on every draw.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, enableExclusivePropsUpdateAndroid: { dangerouslyExposeInPublicCppHeaders: true, defaultValue: false, diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index e5a8120cff62..2d3a41b0efe9 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<<674f975884ae746e067e614523f2bfc3>> + * @generated SignedSource<<39f76cb701cafc08a547b1f222ab9889>> * @flow strict * @noformat */ @@ -73,6 +73,7 @@ export type ReactNativeFeatureFlags = Readonly<{ enableDestroyShadowTreeRevisionAsync: Getter, enableDoubleMeasurementFixAndroid: Getter, enableEagerRootViewAttachment: Getter, + enableEllipsizedTextTrimmingAndroid: Getter, enableExclusivePropsUpdateAndroid: Getter, enableFabricCommitBranching: Getter, enableFabricCommitBranchingMergeOnMainThread: Getter, @@ -318,6 +319,10 @@ export const enableDoubleMeasurementFixAndroid: Getter = createNativeFl * Feature flag to configure eager attachment of the root view/initialisation of the JS code. */ export const enableEagerRootViewAttachment: Getter = createNativeFlagGetter('enableEagerRootViewAttachment', false); +/** + * Draws ellipsized text on Android without the text hidden after the ellipsis, so that it is not shaped again on every draw. + */ +export const enableEllipsizedTextTrimmingAndroid: Getter = createNativeFlagGetter('enableEllipsizedTextTrimmingAndroid', false); /** * When enabled, Android will disable Props 1.5 raw value merging when Props 2.0 is available. */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index 6fc752bba2a9..174f9223c6ea 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -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<<2eb3f4a3dca013c51ad2913944855010>> + * @generated SignedSource<<0725b0909ae9dfb60e8ebe1ee1097851>> * @flow strict * @noformat */ @@ -49,6 +49,7 @@ export interface Spec extends TurboModule { readonly enableDestroyShadowTreeRevisionAsync?: () => boolean; readonly enableDoubleMeasurementFixAndroid?: () => boolean; readonly enableEagerRootViewAttachment?: () => boolean; + readonly enableEllipsizedTextTrimmingAndroid?: () => boolean; readonly enableExclusivePropsUpdateAndroid?: () => boolean; readonly enableFabricCommitBranching?: () => boolean; readonly enableFabricCommitBranchingMergeOnMainThread?: () => boolean; From 640a06bf958705fbbb396e6404fe2708cee3129d Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Thu, 29 Oct 2026 21:36:36 +0100 Subject: [PATCH 2/2] Address review: bidi isolates, letter spacing, line widths, and tests - Keep the whole text when it has any right-to-left or directional formatting character. TextDirectionHeuristics.ANYRTL_LTR skips bidi isolates, but their runs are still reordered, so a hidden run could come before visible ones and stop the trimmed line from drawing them. - Keep the whole text when it has letter spacing, whatever the alignment: before Android 15 the hidden characters are spaced too. - Don't delegate getLineMax, getLineWidth, getLineLeft and getLineRight: StaticLayout computes them by shaping its whole last line, hidden text included, so this layout computes them from its own, shorter text. - Check justification and hyphenation in createIfNeeded, look for all the hidden span types in one pass, and move the flag check to TextLayoutManager.layoutForDrawing and the selectable-text lookup to EllipsisTrimmedLayout.untrimmedText, so both can be tested. - Test isolates, letter spacing, justification and hyphenation, stateful spans through MutableSpannableLayout, the feature flag and untrimmedText. Co-Authored-By: Claude Opus 5.5 --- .../react/views/text/EllipsisTrimmedLayout.kt | 114 +++++++----- .../react/views/text/ReactTextViewManager.kt | 2 +- .../react/views/text/TextLayoutManager.kt | 44 +++-- .../views/text/EllipsisTrimmedLayoutTest.kt | 167 +++++++++++++----- 4 files changed, 217 insertions(+), 110 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EllipsisTrimmedLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EllipsisTrimmedLayout.kt index abd122709b02..f9d9902f3f81 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EllipsisTrimmedLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/EllipsisTrimmedLayout.kt @@ -7,10 +7,10 @@ package com.facebook.react.views.text +import android.os.Build import android.text.Layout import android.text.SpannableString import android.text.Spanned -import android.text.TextDirectionHeuristics import android.text.style.ClickableSpan import android.text.style.ReplacementSpan import com.facebook.react.common.annotations.UnstableReactNativeAPI @@ -43,17 +43,27 @@ private constructor( private val lastLine = delegate.lineCount - 1 - /** The layout this one draws, with the whole text. */ - val originalLayout: Layout - get() = delegate - companion object { /** - * Returns an [EllipsisTrimmedLayout] if [layout], a layout of [source] that isn't justified or - * hyphenated, is ellipsized at the end and hides text that only matters for drawing, else null. + * Returns an [EllipsisTrimmedLayout] if [layout], a layout of [source], is ellipsized at the + * end and hides text that only matters for drawing, else null. */ @OptIn(UnstableReactNativeAPI::class) - fun createIfNeeded(layout: Layout, source: Spanned): EllipsisTrimmedLayout? { + fun createIfNeeded( + layout: Layout, + source: Spanned, + hyphenationFrequency: Int, + justificationMode: Int, + ): EllipsisTrimmedLayout? { + // Justified lines and hyphens are drawn from the layout's justification mode and hyphen + // edits, which a delegating layout doesn't have. + if ( + hyphenationFrequency != Layout.HYPHENATION_FREQUENCY_NONE || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + justificationMode != Layout.JUSTIFICATION_MODE_NONE) + ) { + return null + } val lastLine = layout.lineCount - 1 if (lastLine < 0 || layout.getEllipsisCount(lastLine) <= 1) { return null @@ -66,38 +76,30 @@ private constructor( return null } val end = ellipsisStart + 1 - // Keep the text whole when its hidden part matters beyond drawing: line directions are - // computed over the whole line, and text with right-to-left characters anywhere gets - // bounds-based drawing on Android 15+, which this layout doesn't have; links are exposed to - // accessibility, inline views are measured, replacement spans and text effects draw or work - // on the range they were given, and so do touchable spans. - if ( - TextDirectionHeuristics.ANYRTL_LTR.isRtl(source, 0, source.length) || - source.getSpans(end, source.length, ClickableSpan::class.java).isNotEmpty() || - source.getSpans(end, source.length, ReplacementSpan::class.java).isNotEmpty() || - source.getSpans(end, source.length, TextEffectSpan::class.java).isNotEmpty() || - source.getSpans(end, source.length, TouchableSpan::class.java).isNotEmpty() - ) { + // Keep the text whole when its hidden part matters beyond drawing. Bidirectional text is laid + // out in runs over the whole line, which can put hidden runs before visible ones, and on + // Android 15+ text with right-to-left characters is drawn with bounds-based width, which this + // layout doesn't have. Letter spacing also spaces the hidden characters before Android 15. + if (layout.paint.letterSpacing != 0f || mayNeedBidi(source)) { return null } - // Before Android 15, letter spacing also spaces the hidden characters, and a centered or - // end-aligned line is drawn from a width that includes them. - if ( - layout.alignment != Layout.Alignment.ALIGN_NORMAL && - (layout.paint.letterSpacing != 0f || - source - .getSpans( - end, - layout.getLineEnd(lastLine), - CustomLetterSpacingSpan::class.java, - ) - .isNotEmpty()) - ) { - return null + // Links are exposed to accessibility, replacement spans such as inline views are measured + // and drawn over their whole range, and text effects, touchable spans and letter spacing + // work on the range they were given. + for (span in source.getSpans(end, source.length, Any::class.java)) { + if ( + span is ClickableSpan || + span is ReplacementSpan || + span is TextEffectSpan || + span is TouchableSpan || + span is CustomLetterSpacingSpan + ) { + return null + } } // The layout's text already has the ellipsis character in place. val trimmedText = layout.text.subSequence(0, end) - if (trimmedText[trimmedText.length - 1] != '\u2026') { + if (trimmedText[trimmedText.length - 1] != '…') { return null } return EllipsisTrimmedLayout( @@ -105,6 +107,36 @@ private constructor( trimmedText as? SpannableString ?: SpannableString(trimmedText), ) } + + /** The whole text of [layout], also when it's an [EllipsisTrimmedLayout]. */ + fun untrimmedText(layout: Layout): CharSequence = + ((layout as? EllipsisTrimmedLayout)?.delegate ?: layout).text + + /** + * Whether [text] has a right-to-left character or a directional formatting character. Unlike + * `TextDirectionHeuristics.ANYRTL_LTR`, this also sees right-to-left text inside isolates. + */ + private fun mayNeedBidi(text: CharSequence): Boolean { + var i = 0 + while (i < text.length) { + val codePoint = Character.codePointAt(text, i) + when (Character.getDirectionality(codePoint)) { + Character.DIRECTIONALITY_RIGHT_TO_LEFT, + Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC, + Character.DIRECTIONALITY_RIGHT_TO_LEFT_EMBEDDING, + Character.DIRECTIONALITY_RIGHT_TO_LEFT_OVERRIDE, + Character.DIRECTIONALITY_RIGHT_TO_LEFT_ISOLATE, + Character.DIRECTIONALITY_LEFT_TO_RIGHT_EMBEDDING, + Character.DIRECTIONALITY_LEFT_TO_RIGHT_OVERRIDE, + Character.DIRECTIONALITY_LEFT_TO_RIGHT_ISOLATE, + Character.DIRECTIONALITY_FIRST_STRONG_ISOLATE, + Character.DIRECTIONALITY_POP_DIRECTIONAL_FORMAT, + Character.DIRECTIONALITY_POP_DIRECTIONAL_ISOLATE -> return true + } + i += Character.charCount(codePoint) + } + return false + } } // --- Abstract methods: delegated, except where the last line ends --- @@ -134,17 +166,11 @@ private constructor( override fun getParagraphDirection(line: Int): Int = delegate.getParagraphDirection(line) - // --- Non-abstract overrides: StaticLayout's fast paths, as in MutableSpannableLayout --- - override fun getEllipsizedWidth(): Int = delegate.ellipsizedWidth - override fun getLineMax(line: Int): Float = delegate.getLineMax(line) - - override fun getLineWidth(line: Int): Float = delegate.getLineWidth(line) - - override fun getLineLeft(line: Int): Float = delegate.getLineLeft(line) - - override fun getLineRight(line: Int): Float = delegate.getLineRight(line) + // Line widths (getLineMax, getLineWidth, getLineLeft, getLineRight) are not delegated: the + // original computes them by shaping its whole last line, hidden text included, and this layout + // gets the same values from its own, shorter text. // Only called by the framework on API 33+ @android.annotation.SuppressLint("NewApi") diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index 118c7bd208bf..14c25f1647a9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -217,7 +217,7 @@ public constructor( val layout = preparedLayout.layout // The TextView lays the text out again, so it needs the whole text, not the drawn text trimmed // after an ellipsis. - val text = ((layout as? EllipsisTrimmedLayout)?.originalLayout ?: layout).text + val text = EllipsisTrimmedLayout.untrimmedText(layout) val spanned = if (text is Spannable) text else SpannableString(text) view.setSpanned(spanned) view.setPreparedLayout(preparedLayout) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index f8ef3ca47050..9349bbb75ac5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -1439,23 +1439,13 @@ internal object TextLayoutManager { maximumNumberOfLines, ) - // Prepared layouts are drawn, so drop the text hidden after an ellipsis. Justified lines and - // hyphens are drawn from the justification mode and hyphen edits, which a delegating layout - // doesn't have. - val layout = - if ( - ReactNativeFeatureFlags.enableEllipsizedTextTrimmingAndroid() && - result.hyphenationFrequency == Layout.HYPHENATION_FREQUENCY_NONE && - (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || - result.justificationMode == Layout.JUSTIFICATION_MODE_NONE) - ) { - EllipsisTrimmedLayout.createIfNeeded(result.layout, text) ?: result.layout - } else { - result.layout - } - return PreparedLayout( - layout, + layoutForDrawing( + result.layout, + text, + result.hyphenationFrequency, + result.justificationMode, + ), maximumNumberOfLines, verticalOffset, reactTags, @@ -1467,6 +1457,28 @@ internal object TextLayoutManager { ) } + /** + * The layout a prepared layout draws: [layout] without the text hidden after an ellipsis, when + * that is enabled and possible. + */ + @VisibleForTesting + internal fun layoutForDrawing( + layout: Layout, + text: Spanned, + hyphenationFrequency: Int, + justificationMode: Int, + ): Layout { + if (!ReactNativeFeatureFlags.enableEllipsizedTextTrimmingAndroid()) { + return layout + } + return EllipsisTrimmedLayout.createIfNeeded( + layout, + text, + hyphenationFrequency, + justificationMode, + ) ?: layout + } + @JvmStatic fun adjustSpannableFontToFit( text: Spannable, diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/EllipsisTrimmedLayoutTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/EllipsisTrimmedLayoutTest.kt index 9f64d4bda1b2..2758370e7299 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/EllipsisTrimmedLayoutTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/EllipsisTrimmedLayoutTest.kt @@ -27,12 +27,18 @@ import android.text.style.ReplacementSpan import android.text.style.StyleSpan import android.view.View import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan +import com.facebook.react.views.text.internal.span.StatefulSpan import com.facebook.react.views.text.internal.span.TextEffectSpan import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan import com.facebook.react.views.text.internal.span.TouchableSpan import com.facebook.yoga.YogaMeasureMode import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -45,6 +51,16 @@ import org.robolectric.annotation.GraphicsMode @GraphicsMode(GraphicsMode.Mode.NATIVE) class EllipsisTrimmedLayoutTest { + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + } + + @After + fun tearDown() { + ReactNativeFeatureFlags.dangerouslyReset() + } + private val longText = (1..40).joinToString(" ") { "word$it" } @Test @@ -52,11 +68,12 @@ class EllipsisTrimmedLayoutTest { val text = SpannableString(longText) val layout = createLayout(text, maxLines = 1) - val trimmed = checkNotNull(EllipsisTrimmedLayout.createIfNeeded(layout, text)) + val trimmed = checkNotNull(trim(layout, text)) assertThat(trimmed.text.length).isEqualTo(layout.getEllipsisStart(0) + 1) assertThat(trimmed.text[trimmed.text.length - 1]).isEqualTo('…') - assertThat(trimmed.originalLayout).isSameAs(layout) + assertThat(EllipsisTrimmedLayout.untrimmedText(trimmed)).isSameAs(layout.text) + assertThat(EllipsisTrimmedLayout.untrimmedText(layout)).isSameAs(layout.text) assertSameLayout(trimmed, layout) } @@ -72,7 +89,7 @@ class EllipsisTrimmedLayoutTest { ) val layout = createLayout(text, maxLines = 3) - val trimmed = checkNotNull(EllipsisTrimmedLayout.createIfNeeded(layout, text)) + val trimmed = checkNotNull(trim(layout, text)) assertThat(trimmed.text.length).isLessThan(text.length) assertSameLayout(trimmed, layout) @@ -83,7 +100,7 @@ class EllipsisTrimmedLayoutTest { val text = SpannableString(longText + "\n" + longText + "\n" + longText) val layout = createLayout(text, maxLines = 2) - val trimmed = checkNotNull(EllipsisTrimmedLayout.createIfNeeded(layout, text)) + val trimmed = checkNotNull(trim(layout, text)) assertThat(trimmed.text.length).isLessThan(text.length) assertSameLayout(trimmed, layout) @@ -95,50 +112,42 @@ class EllipsisTrimmedLayoutTest { val text = SpannableString(longText) val layout = createLayout(text, maxLines = 2, alignment = alignment) - val trimmed = checkNotNull(EllipsisTrimmedLayout.createIfNeeded(layout, text)) + val trimmed = checkNotNull(trim(layout, text)) assertSameLayout(trimmed, layout) } } @Test - fun `text with letter spacing draws the same`() { - val text = SpannableString(longText) - text.setSpan( - CustomLetterSpacingSpan(0.1f), - 0, - text.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - val layout = createLayout(text, maxLines = 2, textPaint = spacedPaint) - - val trimmed = checkNotNull(EllipsisTrimmedLayout.createIfNeeded(layout, text)) + fun `text with letter spacing is kept`() { + // Before Android 15, letter spacing also spaces the hidden characters. + for (alignment in listOf(Layout.Alignment.ALIGN_NORMAL, Layout.Alignment.ALIGN_CENTER)) { + val spanned = SpannableString(longText) + spanned.setSpan( + CustomLetterSpacingSpan(0.1f), + spanned.length - 5, + spanned.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val spannedLayout = createLayout(spanned, maxLines = 2, alignment = alignment) + assertThat(trim(spannedLayout, spanned)).isNull() - assertSameLayout(trimmed, layout) + val text = SpannableString(longText) + val layout = createLayout(text, maxLines = 2, alignment = alignment, textPaint = spacedPaint) + assertThat(trim(layout, text)).isNull() + } } @Test - fun `centered text with letter spacing in its hidden text is kept`() { - val spanned = SpannableString(longText) - spanned.setSpan( - CustomLetterSpacingSpan(0.1f), - spanned.length - 5, - spanned.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - val spannedLayout = - createLayout(spanned, maxLines = 2, alignment = Layout.Alignment.ALIGN_CENTER) - assertThat(EllipsisTrimmedLayout.createIfNeeded(spannedLayout, spanned)).isNull() - + fun `justified or hyphenated text is kept`() { val text = SpannableString(longText) - val layout = - createLayout( - text, - maxLines = 2, - alignment = Layout.Alignment.ALIGN_CENTER, - textPaint = spacedPaint, - ) - assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + val layout = createLayout(text, maxLines = 2) + + assertThat(trim(layout, text)).isNotNull() + assertThat(trim(layout, text, justificationMode = Layout.JUSTIFICATION_MODE_INTER_WORD)) + .isNull() + assertThat(trim(layout, text, hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NORMAL)) + .isNull() } @Test @@ -146,7 +155,7 @@ class EllipsisTrimmedLayoutTest { val text = SpannableString("word1 word2") val layout = createLayout(text, maxLines = 1) - assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + assertThat(trim(layout, text)).isNull() } @Test @@ -154,7 +163,7 @@ class EllipsisTrimmedLayoutTest { val text = SpannableString("word1\nword2") val layout = createLayout(text, maxLines = 1) - assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + assertThat(trim(layout, text)).isNull() } @Test @@ -163,7 +172,7 @@ class EllipsisTrimmedLayoutTest { val layout = createLayout(text, maxLines = 1, ellipsizeMode = TextUtils.TruncateAt.MIDDLE) assertThat(layout.getEllipsisCount(0)).isGreaterThan(1) - assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + assertThat(trim(layout, text)).isNull() } @Test @@ -171,7 +180,17 @@ class EllipsisTrimmedLayoutTest { val text = SpannableString((1..40).joinToString(" ") { "שלום$it" }) val layout = createLayout(text, maxLines = 1) - assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + assertThat(trim(layout, text)).isNull() + } + + @Test + fun `right-to-left text inside an isolate is kept`() { + // TextDirectionHeuristics.ANYRTL_LTR skips isolates, but their runs are still reordered. + val text = SpannableString("From \u2068محمد 2024 السعيد\u2069 $longText") + val layout = createLayout(text, maxLines = 1) + + assertThat(layout.getEllipsisCount(0)).isGreaterThan(1) + assertThat(trim(layout, text)).isNull() } @Test @@ -181,17 +200,14 @@ class EllipsisTrimmedLayoutTest { val layout = createLayout(text, maxLines = 1) assertThat(layout.getEllipsisCount(0)).isGreaterThan(1) - assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + assertThat(trim(layout, text)).isNull() } @Test fun `a link in the visible text is kept and one in the hidden text keeps the whole text`() { val visible = SpannableString(longText) visible.setSpan(ClickableLink(), 0, 5, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - val trimmed = - checkNotNull( - EllipsisTrimmedLayout.createIfNeeded(createLayout(visible, maxLines = 1), visible) - ) + val trimmed = checkNotNull(trim(createLayout(visible, maxLines = 1), visible)) val trimmedText = trimmed.text as Spannable assertThat(trimmedText.getSpans(0, trimmedText.length, ClickableLink::class.java)).hasSize(1) @@ -200,8 +216,7 @@ class EllipsisTrimmedLayoutTest { // A link that starts in the visible text and continues into the hidden text. val crossing = SpannableString(longText) crossing.setSpan(ClickableLink(), 5, crossing.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - assertThat(EllipsisTrimmedLayout.createIfNeeded(createLayout(crossing, maxLines = 1), crossing)) - .isNull() + assertThat(trim(createLayout(crossing, maxLines = 1), crossing)).isNull() } @Test @@ -238,6 +253,48 @@ class EllipsisTrimmedLayoutTest { ) } + @Test + fun `stateful spans in the visible text draw the same through MutableSpannableLayout`() { + val text = SpannableString(longText) + text.setSpan(ColorStatefulSpan(), 0, 10, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val layout = createLayout(text, maxLines = 2) + val trimmed = checkNotNull(trim(layout, text)) + + val original = checkNotNull(MutableSpannableLayout.createIfNeeded(layout)) + val mutableTrimmed = checkNotNull(MutableSpannableLayout.createIfNeeded(trimmed)) + + assertSameLayout(mutableTrimmed, original) + } + + @Test + fun `prepared layouts are trimmed only with the feature flag`() { + val text = SpannableString(longText) + val layout = createLayout(text, maxLines = 2) + val none = Layout.HYPHENATION_FREQUENCY_NONE + val noJustification = Layout.JUSTIFICATION_MODE_NONE + + assertThat(TextLayoutManager.layoutForDrawing(layout, text, none, noJustification)) + .isSameAs(layout) + + ReactNativeFeatureFlags.dangerouslyReset() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enableEllipsizedTextTrimmingAndroid(): Boolean = true + } + ) + assertThat(TextLayoutManager.layoutForDrawing(layout, text, none, noJustification)) + .isInstanceOf(EllipsisTrimmedLayout::class.java) + assertThat( + TextLayoutManager.layoutForDrawing( + layout, + text, + Layout.HYPHENATION_FREQUENCY_NORMAL, + noJustification, + ) + ) + .isSameAs(layout) + } + @Test fun `a text effect in the hidden text keeps the whole text`() { assertHiddenSpanKeepsText(object : TextEffectSpan {}) @@ -252,13 +309,21 @@ class EllipsisTrimmedLayoutTest { ) } + private fun trim( + layout: Layout, + text: Spannable, + hyphenationFrequency: Int = Layout.HYPHENATION_FREQUENCY_NONE, + justificationMode: Int = Layout.JUSTIFICATION_MODE_NONE, + ): EllipsisTrimmedLayout? = + EllipsisTrimmedLayout.createIfNeeded(layout, text, hyphenationFrequency, justificationMode) + private fun assertHiddenSpanKeepsText(span: Any) { val text = SpannableString(longText) text.setSpan(span, text.length - 1, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) val layout = createLayout(text, maxLines = 1) assertThat(layout.getEllipsisCount(0)).isGreaterThan(1) - assertThat(EllipsisTrimmedLayout.createIfNeeded(layout, text)).isNull() + assertThat(trim(layout, text)).isNull() } private fun assertSameLayout(trimmed: Layout, layout: Layout) { @@ -328,6 +393,10 @@ class EllipsisTrimmedLayoutTest { override fun onClick(widget: View) = Unit } + private class ColorStatefulSpan : ForegroundColorSpan(Color.BLUE), StatefulSpan { + override fun clone(): StatefulSpan = ColorStatefulSpan() + } + private companion object { val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f } val spacedPaint = TextPaint(paint).apply { letterSpacing = 0.1f }