Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<<a89ede36c4631b7a7709899858a7f9c9>>
* @generated SignedSource<<2143f0faa5bbc2cc77e304adc3edf65c>>
*/

/**
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<cd3b4f6a7fb4e616d3f56ab8c9eb9658>>
* @generated SignedSource<<eed90453ba779cdf384f7c4f852f8cf1>>
*/

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<6d86d4adbc261c9271b5bed72bd12dcf>>
* @generated SignedSource<<2f52b0ca227f2fc6e797caf49e810082>>
*/

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<a1368426dd823245c54b4312ae7b14e6>>
* @generated SignedSource<<e03f517d0d79b04442bb3ef996db81e1>>
*/

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<c305274be2fc6720b6c0eb75e78bd6b0>>
* @generated SignedSource<<40431db1f1e82e88b4209490990137dd>>
*/

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<da45e41e9a58711ee3b68a38965938fb>>
* @generated SignedSource<<02e60f60410efd918ad09ffc46a3bd3d>>
*/

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* 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.os.Build
import android.text.Layout
import android.text.SpannableString
import android.text.Spanned
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

companion object {
/**
* 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,
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
}
// 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. 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
}
// 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] != '…') {
return null
}
return EllipsisTrimmedLayout(
layout,
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 ---

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)

override fun getEllipsizedWidth(): Int = delegate.ellipsizedWidth

// 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")
override fun isFallbackLineSpacingEnabled(): Boolean = delegate.isFallbackLineSpacingEnabled
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = EllipsisTrimmedLayout.untrimmedText(layout)
val spanned = if (text is Spannable) text else SpannableString(text)
view.setSpanned(spanned)
view.setPreparedLayout(preparedLayout)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,7 @@ internal object TextLayoutManager {
return CreateLayoutResult(
layout,
textBreakStrategy,
hyphenationFrequency,
justificationMode,
)
}
Expand Down Expand Up @@ -1439,7 +1440,12 @@ internal object TextLayoutManager {
)

return PreparedLayout(
result.layout,
layoutForDrawing(
result.layout,
text,
result.hyphenationFrequency,
result.justificationMode,
),
maximumNumberOfLines,
verticalOffset,
reactTags,
Expand All @@ -1451,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,
Expand Down Expand Up @@ -1960,6 +1988,7 @@ internal object TextLayoutManager {
private class CreateLayoutResult(
val layout: Layout,
val textBreakStrategy: Int,
val hyphenationFrequency: Int,
val justificationMode: Int,
)

Expand Down
Loading
Loading