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 new file mode 100644 index 000000000000..c4e16d8718bc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt @@ -0,0 +1,107 @@ +/* + * 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 com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.views.text.internal.span.StatefulSpan + +/** + * 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. + * + * The mutable [Spannable] 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. + * [Layout.getText] is final and returns `mText` set by the protected constructor, so the cloned + * [SpannableString] is passed there directly. + */ +internal class MutableSpannableLayout +private constructor( + private val delegate: Layout, + clonedText: SpannableString, +) : + Layout( + clonedText, + delegate.paint, + delegate.width, + delegate.alignment, + delegate.spacingMultiplier, + delegate.spacingAdd, + ) { + + 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) + if (statefulSpans.isEmpty()) { + return null + } + + val cloned = SpannableString(spanned) + 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) + } + return MutableSpannableLayout(layout, cloned) + } + } + + // --- 10 abstract methods — delegate to original --- + + 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 = 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 = delegate.getEllipsisCount(line) + + override fun getParagraphDirection(line: Int): Int = delegate.getParagraphDirection(line) + + // --- Non-abstract overrides for performance/correctness --- + // StaticLayout overrides these with optimized implementations. Delegating + // ensures we get the original's fast paths rather than Layout's base + // implementations that recompute from scratch. + + 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/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 1b4e659a2a85..e3a0c122e4a9 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 @@ -48,10 +48,14 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re var preparedLayout: PreparedLayout? = null set(value) { if (field != value) { + val effectiveValue = value?.maybeProxyStatefulSpans() val lastSelection = selection if (lastSelection != null) { - if (value != null && field?.layout?.text.toString() == value.layout.text.toString()) { - value.layout.getSelectionPath( + if ( + effectiveValue != null && + field?.layout?.text.toString() == effectiveValue.layout.text.toString() + ) { + effectiveValue.layout.getSelectionPath( lastSelection.start, lastSelection.end, lastSelection.path, @@ -61,9 +65,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } } - clickableSpans = value?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList() + clickableSpans = + effectiveValue?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList() - field = value + field = effectiveValue invalidate() } } @@ -393,5 +398,21 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re return spans } + + /** + * If the layout contains [StatefulSpan]s, returns a new [PreparedLayout] whose spannable has + * independent clones of those spans. Otherwise returns the receiver unchanged. + */ + private fun PreparedLayout.maybeProxyStatefulSpans(): PreparedLayout { + val proxyLayout = MutableSpannableLayout.createIfNeeded(layout) ?: return this + return PreparedLayout( + proxyLayout, + maximumNumberOfLines, + verticalOffset, + reactTags, + textBreakStrategy, + justificationMode, + ) + } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StatefulSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StatefulSpan.kt new file mode 100644 index 000000000000..cc8f09284efa --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StatefulSpan.kt @@ -0,0 +1,21 @@ +/* + * 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.internal.span + +import com.facebook.react.common.annotations.UnstableReactNativeAPI + +/** + * Marker interface for spans that hold per-view mutable state (e.g. animation particles, dismiss + * flags). When a [PreparedLayout] contains stateful spans, [PreparedLayoutTextView] clones the + * spannable so that each view gets independent state even when layouts are shared from a cache. + */ +@UnstableReactNativeAPI +public interface StatefulSpan { + /** Returns a fresh instance with the same configuration but independent mutable state. */ + public fun clone(): StatefulSpan +}