diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index 97de9935b8cf..c3cd11723364 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -706,13 +706,7 @@ public PreparedLayout prepareTextLayout( @UnstableReactNativeAPI public PreparedLayout reusePreparedLayoutWithNewReactTags( PreparedLayout preparedLayout, int[] reactTags) { - return new PreparedLayout( - preparedLayout.getLayout(), - preparedLayout.getMaximumNumberOfLines(), - preparedLayout.getVerticalOffset(), - reactTags, - preparedLayout.getTextBreakStrategy(), - preparedLayout.getJustificationMode()); + return preparedLayout.copyWithReactTags(reactTags); } @AnyThread diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt index c4e16d8718bc..d61916d9a583 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt @@ -12,14 +12,16 @@ import android.text.SpannableString import android.text.Spanned import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.views.text.internal.span.StatefulSpan +import java.util.IdentityHashMap /** - * A delegating [Layout] subclass that clones the spannable text from [delegate] and replaces all - * [StatefulSpan] instances with fresh clones. This gives each [PreparedLayoutTextView] independent - * mutable span state (e.g. particle animation, dismiss state) even when the underlying [Layout] is - * shared from a cache. + * A delegating [Layout] subclass that clones both the display-shaped text from [delegate] and its + * full source text. Corresponding [StatefulSpan] instances in both copies are replaced with the + * same fresh clone, while spans present in only one copy are cloned on demand. This gives each + * [PreparedLayoutTextView] independent mutable span state (e.g. particle animation, dismiss state) + * even when the underlying [Layout] is shared from a cache. * - * The mutable [Spannable] can be useful for spans which affect display, but do not alter existing + * The mutable display text can be useful for spans which affect display, but do not alter existing * layout calculations. * * Line metrics are delegated to [delegate] so no expensive [StaticLayout] rebuild is needed. @@ -30,6 +32,7 @@ internal class MutableSpannableLayout private constructor( private val delegate: Layout, clonedText: SpannableString, + val sourceText: Spanned, ) : Layout( clonedText, @@ -43,22 +46,39 @@ private constructor( companion object { /** Returns a [MutableSpannableLayout] if [layout] contains stateful spans, else null. */ @OptIn(UnstableReactNativeAPI::class) - fun createIfNeeded(layout: Layout): MutableSpannableLayout? { - val spanned = layout.text as? Spanned ?: return null - val statefulSpans = spanned.getSpans(0, spanned.length, StatefulSpan::class.java) + fun createIfNeeded(layout: Layout, sourceText: Spanned): MutableSpannableLayout? { + val layoutText = layout.text as? Spanned ?: return null + val statefulSpans = layoutText.getSpans(0, layoutText.length, StatefulSpan::class.java) if (statefulSpans.isEmpty()) { return null } - val cloned = SpannableString(spanned) + val spanClones = IdentityHashMap() + val clonedSourceText = SpannableString(sourceText) + for (oldSpan in sourceText.getSpans(0, sourceText.length, StatefulSpan::class.java)) { + val newSpan = spanClones.getOrPut(oldSpan) { oldSpan.clone() } + replaceSpan(clonedSourceText, oldSpan, newSpan) + } + + val clonedLayoutText = SpannableString(layoutText) for (oldSpan in statefulSpans) { - val start = cloned.getSpanStart(oldSpan) - val end = cloned.getSpanEnd(oldSpan) - val flags = cloned.getSpanFlags(oldSpan) - cloned.removeSpan(oldSpan) - cloned.setSpan(oldSpan.clone(), start, end, flags) + val newSpan = spanClones.getOrPut(oldSpan) { oldSpan.clone() } + replaceSpan(clonedLayoutText, oldSpan, newSpan) } - return MutableSpannableLayout(layout, cloned) + return MutableSpannableLayout(layout, clonedLayoutText, clonedSourceText) + } + + @OptIn(UnstableReactNativeAPI::class) + private fun replaceSpan( + text: SpannableString, + oldSpan: StatefulSpan, + newSpan: StatefulSpan, + ) { + val start = text.getSpanStart(oldSpan) + val end = text.getSpanEnd(oldSpan) + val flags = text.getSpanFlags(oldSpan) + text.removeSpan(oldSpan) + text.setSpan(newSpan, start, end, flags) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt index 51836c884989..075a162eec4f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt @@ -8,18 +8,31 @@ package com.facebook.react.views.text import android.text.Layout +import android.text.Spanned import com.facebook.proguard.annotations.DoNotStrip /** - * Encapsulates an {android.text.Layout} along with any additional state needed to render or measure - * it. + * Encapsulates an [android.text.Layout] along with the full source text and any additional state + * needed to render or measure it. */ @DoNotStrip internal class PreparedLayout( val layout: Layout, + val sourceText: Spanned, val maximumNumberOfLines: Int, val verticalOffset: Float, val reactTags: IntArray, val textBreakStrategy: Int, val justificationMode: Int, -) +) { + fun copyWithReactTags(reactTags: IntArray): PreparedLayout = + PreparedLayout( + layout, + sourceText, + maximumNumberOfLines, + verticalOffset, + reactTags, + textBreakStrategy, + justificationMode, + ) +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 4202be214106..48ec8f125879 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -93,7 +93,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val text: CharSequence? // Avoid mangling the getter name, to allow black box E2E tests to read text content via // reflection - @DoNotStrip get() = preparedLayout?.layout?.text + @DoNotStrip get() = preparedLayout?.sourceText init { // ViewGroup by default says only its children will draw @@ -136,7 +136,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re selectionColor ?: DefaultStyleValuesUtil.getDefaultTextColorHighlight(context) } - val spanned = text as? Spanned + val spanned = layout.text as? Spanned val canvasEffectSpans = spanned?.getSpans(0, spanned.length, CanvasEffectSpan::class.java) ?: emptyArray() @@ -242,7 +242,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val action = event.actionMasked if (action == MotionEvent.ACTION_CANCEL) { // Forward ACTION_CANCEL to all TouchableSpans so they can reset pressed/animation state - val spanned = text as? Spanned + val spanned = preparedLayout?.layout?.text as? Spanned for (span in spanned?.getSpans(0, spanned.length, TouchableSpan::class.java).orEmpty()) { span.onTouchEvent(action, 0f, 0f) } @@ -314,7 +314,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re return null } - val spanned = text as? Spanned ?: return null + val spanned = preparedLayout?.layout?.text as? Spanned ?: return null val spans = spanned.getSpans(offset, offset, clazz) if (spans.isEmpty()) { @@ -490,13 +490,15 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } /** - * If the layout contains [StatefulSpan]s, returns a new [PreparedLayout] whose spannable has - * independent clones of those spans. Otherwise returns the receiver unchanged. + * If the layout contains [StatefulSpan]s, returns a new [PreparedLayout] whose display and + * source text share independent clones of those spans. Otherwise returns the receiver + * unchanged. */ private fun PreparedLayout.maybeProxyStatefulSpans(): PreparedLayout { - val proxyLayout = MutableSpannableLayout.createIfNeeded(layout) ?: return this + val proxyLayout = MutableSpannableLayout.createIfNeeded(layout, sourceText) ?: return this return PreparedLayout( proxyLayout, + proxyLayout.sourceText, maximumNumberOfLines, verticalOffset, reactTags, 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 92d781317a8d..1149b580ef7b 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 @@ -17,6 +17,7 @@ import android.text.Spanned import android.text.TextUtils import android.text.util.Linkify import android.view.Gravity +import androidx.annotation.VisibleForTesting import com.facebook.common.logging.FLog import com.facebook.react.R import com.facebook.react.bridge.Dynamic @@ -207,13 +208,13 @@ public constructor( /** * Constructs a [ReactTextUpdate] from a [PreparedLayout] received via [ReferenceStateWrapper]. */ - private fun getReactTextUpdateFromPreparedLayout( + @VisibleForTesting + internal fun getReactTextUpdateFromPreparedLayout( view: ReactTextView, preparedLayout: PreparedLayout, ): ReactTextUpdate { val layout = preparedLayout.layout - val text = layout.text - val spanned = if (text is Spannable) text else SpannableString(text) + val spanned = SpannableString(preparedLayout.sourceText) 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 d9c28ca5fa20..8d46030a2f7a 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 @@ -1328,6 +1328,7 @@ internal object TextLayoutManager { return PreparedLayout( result.layout, + text, maximumNumberOfLines, verticalOffset, reactTags, diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewTest.kt new file mode 100644 index 000000000000..7e68eb21eff4 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewTest.kt @@ -0,0 +1,250 @@ +/* + * 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.annotation.SuppressLint +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.ClickableSpan +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.views.text.internal.span.StatefulSpan +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@OptIn(UnstableReactNativeAPI::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@SuppressLint("NewApi") +class PreparedLayoutTextViewTest { + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + } + + @Test + fun ellipsizedLayoutExposesFullSourceTextWithoutChangingDisplayText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = true) + val displayText = preparedLayout.layout.text.toString() + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + view.preparedLayout = preparedLayout + + assertThat(displayText).contains(ELLIPSIS) + assertThat(displayText).contains(ELLIPSIS_FILLER) + assertThat(displayText).isNotEqualTo(SOURCE_TEXT) + assertThat(displayText.length).isEqualTo(SOURCE_TEXT.length) + assertThat(view.text.toString()).isEqualTo(SOURCE_TEXT) + assertThat(view.preparedLayout?.layout?.text.toString()).isEqualTo(displayText) + } + + @Test + fun nonEllipsizedLayoutExposesFullSourceText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = false) + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + view.preparedLayout = preparedLayout + + assertThat(preparedLayout.layout.text.toString()).isEqualTo(SOURCE_TEXT) + assertThat(view.text.toString()).isEqualTo(SOURCE_TEXT) + } + + @Test + fun ellipsizedSpannedTextRetainsFullClickableSourceText() { + val clickableSpan = + object : ClickableSpan() { + override fun onClick(widget: View) = Unit + } + val sourceText = SpannableString(SOURCE_TEXT) + sourceText.setSpan( + clickableSpan, + 0, + sourceText.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val preparedLayout = createPreparedLayout(sourceText, ellipsize = true) + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + view.preparedLayout = preparedLayout + + val semanticText = view.text as Spanned + assertThat(semanticText.toString()).isEqualTo(SOURCE_TEXT) + assertThat(semanticText.getSpans(0, semanticText.length, ClickableSpan::class.java)) + .containsExactly(clickableSpan) + assertThat(preparedLayout.layout.text.toString()).contains(ELLIPSIS) + } + + @Test + fun accessibilityDelegateExposesFullSourceText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = true) + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + view.preparedLayout = preparedLayout + val delegate = ReactTextViewAccessibilityDelegate(view, false, view.importantForAccessibility) + val nodeInfo = AccessibilityNodeInfoCompat.obtain() + + delegate.onInitializeAccessibilityNodeInfo(view, nodeInfo) + + assertThat(nodeInfo.text.toString()).isEqualTo(SOURCE_TEXT) + assertThat(preparedLayout.layout.text.toString()).contains(ELLIPSIS) + } + + @Test + fun managerInstallsTextDelegateForPlainPreparedText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = true) + val manager = PreparedLayoutTextViewManager() + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + manager.updateExtraData(view, preparedLayout) + + val delegate = ViewCompat.getAccessibilityDelegate(view) + assertThat(delegate).isInstanceOf(ReactTextViewAccessibilityDelegate::class.java) + val nodeInfo = AccessibilityNodeInfoCompat.obtain() + checkNotNull(delegate).onInitializeAccessibilityNodeInfo(view, nodeInfo) + assertThat(nodeInfo.text.toString()).isEqualTo(SOURCE_TEXT) + } + + @Test + fun legacyTextViewFallbackUsesFullSourceText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = true) + val manager = ReactTextViewManager() + val view = ReactTextView(RuntimeEnvironment.getApplication()) + + val update = manager.getReactTextUpdateFromPreparedLayout(view, preparedLayout) + + assertThat(update.text.toString()).isEqualTo(SOURCE_TEXT) + assertThat(update.text).isNotSameAs(preparedLayout.sourceText) + assertThat(view.spanned).isSameAs(update.text) + assertThat(view.preparedLayout).isSameAs(preparedLayout) + assertThat(preparedLayout.layout.text.toString()).contains(ELLIPSIS) + } + + @Test + fun reactTagCopyRetainsFullSourceText() { + val preparedLayout = createPreparedLayout(SOURCE_TEXT, ellipsize = true) + val newReactTags = intArrayOf(10, 20) + + val copiedLayout = preparedLayout.copyWithReactTags(newReactTags) + + assertThat(copiedLayout.sourceText).isSameAs(preparedLayout.sourceText) + assertThat(copiedLayout.layout).isSameAs(preparedLayout.layout) + assertThat(copiedLayout.reactTags).containsExactly(10, 20) + assertThat(copiedLayout.sourceText.toString()).isEqualTo(SOURCE_TEXT) + } + + @Test + fun statefulSpanProxyRetainsFullSourceTextAndSharesPerViewCloneWithDisplayText() { + val originalSpan = TestStatefulSpan() + val sourceText = SpannableString(SOURCE_TEXT) + val spanStart = 5 + val spanEnd = sourceText.length - 5 + sourceText.setSpan( + originalSpan, + spanStart, + spanEnd, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val preparedLayout = createPreparedLayout(sourceText, ellipsize = true) + val firstView = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + val secondView = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + firstView.preparedLayout = preparedLayout + secondView.preparedLayout = preparedLayout + + val firstPreparedLayout = checkNotNull(firstView.preparedLayout) + val secondPreparedLayout = checkNotNull(secondView.preparedLayout) + val firstSourceText = firstPreparedLayout.sourceText + val firstDisplayText = firstPreparedLayout.layout.text as Spanned + val secondSourceText = secondPreparedLayout.sourceText + val firstSourceSpan = firstSourceText.getStatefulSpan() + val firstDisplaySpan = firstDisplayText.getStatefulSpan() + val secondSourceSpan = secondSourceText.getStatefulSpan() + + assertThat(firstPreparedLayout.sourceText.toString()).isEqualTo(SOURCE_TEXT) + assertThat(firstPreparedLayout.layout.text.toString()).contains(ELLIPSIS) + assertThat(firstSourceSpan).isSameAs(firstDisplaySpan) + assertThat(firstSourceSpan).isNotSameAs(originalSpan) + assertThat(firstSourceSpan).isNotSameAs(secondSourceSpan) + assertThat(firstSourceText.getSpanStart(firstSourceSpan)).isEqualTo(spanStart) + assertThat(firstSourceText.getSpanEnd(firstSourceSpan)).isEqualTo(spanEnd) + assertThat(firstDisplayText.getSpanStart(firstDisplaySpan)).isEqualTo(spanStart) + assertThat(firstDisplayText.getSpanEnd(firstDisplaySpan)).isEqualTo(spanEnd) + assertThat(secondSourceText.getSpanStart(secondSourceSpan)).isEqualTo(spanStart) + assertThat(secondSourceText.getSpanEnd(secondSourceSpan)).isEqualTo(spanEnd) + } + + private fun createPreparedLayout(text: CharSequence, ellipsize: Boolean): PreparedLayout { + val sourceText = if (text is Spanned) text else SpannableString(text) + val displayText = if (ellipsize) createEllipsizedDisplayText(sourceText) else sourceText + val layout = + StaticLayout.Builder.obtain( + displayText, + 0, + displayText.length, + TextPaint().apply { textSize = 24f }, + 1000, + ) + .setIncludePad(false) + .build() + + return PreparedLayout( + layout, + sourceText, + if (ellipsize) 1 else -1, + 0f, + intArrayOf(1), + Layout.BREAK_STRATEGY_SIMPLE, + 0, + ) + } + + private fun createEllipsizedDisplayText(sourceText: Spanned): Spanned { + val ellipsisStart = sourceText.length / 2 + val displayText = + SpannableString( + buildString(sourceText.length) { + append(sourceText, 0, ellipsisStart) + append(ELLIPSIS) + repeat(sourceText.length - ellipsisStart - 1) { append(ELLIPSIS_FILLER) } + } + ) + TextUtils.copySpansFrom( + sourceText, + 0, + sourceText.length, + Any::class.java, + displayText, + 0, + ) + return displayText + } + + private fun Spanned.getStatefulSpan(): TestStatefulSpan = + getSpans(0, length, TestStatefulSpan::class.java).single() + + private class TestStatefulSpan : StatefulSpan { + override fun clone(): StatefulSpan = TestStatefulSpan() + } + + private companion object { + const val SOURCE_TEXT = "This is an order created from the data builder" + const val ELLIPSIS = "\u2026" + const val ELLIPSIS_FILLER = "\uFEFF" + } +}