From 1fc87483b4e5a303d30c2d3118831e422f52557b Mon Sep 17 00:00:00 2001 From: Andrew Datsenko Date: Wed, 6 May 2026 05:58:41 -0700 Subject: [PATCH 1/2] Introduce StatefulSpan and MutableSpannableLayout for safe span state isolation (#56688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `PreparedLayoutTextView` can receive shared `PreparedLayout` instances from a C++ LRU cache. Multiple views rendering identical text share the same `Layout` → `Spannable` → span objects. Stateless spans are fine to share, but stateful spans like `SpoilerEffectSpan` (particles, dismiss state) would have one view's tap-dismiss corrupt all other views sharing that layout. Introduces a `StatefulSpan` marker interface whose presence tells `PreparedLayoutTextView` to clone the spannable with fresh span instances. The Layout is reused via a delegating subclass (`MutableSpannableLayout`) that passes the cloned Spannable to `Layout`'s protected constructor and delegates all line metrics to the original. The mutable Spannable can be useful for spans which affect display, but do not alter existing layout calculations. No StaticLayout rebuild needed. Performance: Only views with stateful spans pay the cost. `getSpans()` is O(spans), `SpannableString` copy is O(text+spans). Views without `StatefulSpan` are completely unchanged. Changelog: [internal] Reviewed By: alanleedev Differential Revision: D97415850 --- .../views/text/MutableSpannableLayout.kt | 107 ++++++++++++++++++ .../views/text/PreparedLayoutTextView.kt | 29 ++++- .../views/text/internal/span/StatefulSpan.kt | 21 ++++ 3 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/MutableSpannableLayout.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StatefulSpan.kt 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 +} From 7449644a518fd0754a8fa91edd678e687bf8978c Mon Sep 17 00:00:00 2001 From: Andrew Datsenko Date: Wed, 6 May 2026 05:58:41 -0700 Subject: [PATCH 2/2] Add support for animated effects on spans of text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Adds `AnimatedEffectSpan`, a new span type for animated effects drawn on top of text in `PreparedLayoutTextView`. Unlike `DrawCommandSpan` which provides static `onPreDraw`/`onDraw` hooks, `AnimatedEffectSpan` supports animation via a `requestAnimationFrame`-style API where the span receives a time delta each frame and returns whether it wants another frame. Key design decisions: - Independent from `DrawCommandSpan` — does not subclass it - Does not implement `UpdateAppearance` — animated effects don't affect text measurement or paint state - Implements `ReactSpan` for integration with RN's span management - Annotated `UnstableReactNativeAPI` — callers must opt in - Zero overhead for non-animated text: delta computation and frame scheduling only happen when animated spans exist - Frame timing resets on visibility changes and view recycling to prevent delta spikes Changelog: [Internal] Reviewed By: alanleedev Differential Revision: D97399151 --- .../views/text/PreparedLayoutTextView.kt | 45 +++++++++++++++++++ .../text/internal/span/AnimatedEffectSpan.kt | 37 +++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/AnimatedEffectSpan.kt 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 e3a0c122e4a9..9acc47f4de1e 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 @@ -18,15 +18,18 @@ import android.text.Spanned import android.text.style.ClickableSpan import android.view.KeyEvent import android.view.MotionEvent +import android.view.View import android.view.ViewGroup import androidx.annotation.ColorInt import androidx.annotation.DoNotInline import androidx.annotation.RequiresApi import androidx.core.view.ViewCompat import com.facebook.proguard.annotations.DoNotStrip +import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.ReactCompoundView import com.facebook.react.uimanager.style.Overflow +import com.facebook.react.views.text.internal.span.AnimatedEffectSpan import com.facebook.react.views.text.internal.span.DrawCommandSpan import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan import com.facebook.react.views.text.internal.span.ReactLinkSpan @@ -44,6 +47,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re private var clickableSpans: List = emptyList() private var selection: TextSelection? = null + private var lastFrameTimeNanos: Long = 0L var preparedLayout: PreparedLayout? = null set(value) { @@ -99,9 +103,18 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re clickableSpans = emptyList() selection = null selectionColor = null + lastFrameTimeNanos = 0L preparedLayout = null } + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, visibility) + if (visibility != VISIBLE) { + lastFrameTimeNanos = 0L + } + } + + @OptIn(UnstableReactNativeAPI::class) override fun onDraw(canvas: Canvas) { if (overflow != Overflow.VISIBLE) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) @@ -151,6 +164,38 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re ) } } + + if (spanned != null) { + val animatedEffectSpans = + spanned.getSpans(0, spanned.length, AnimatedEffectSpan::class.java) + + if (animatedEffectSpans.isNotEmpty()) { + val now = System.nanoTime() + val deltaNanos = if (lastFrameTimeNanos == 0L) 0L else now - lastFrameTimeNanos + lastFrameTimeNanos = now + + var needsNextFrame = false + for (span in animatedEffectSpans) { + if ( + span.onDraw( + spanned.getSpanStart(span), + spanned.getSpanEnd(span), + canvas, + layout, + deltaNanos, + ) + ) { + needsNextFrame = true + } + } + + if (needsNextFrame) { + postInvalidateOnAnimation() + } else { + lastFrameTimeNanos = 0L + } + } + } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/AnimatedEffectSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/AnimatedEffectSpan.kt new file mode 100644 index 000000000000..24b985f1bfdb --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/AnimatedEffectSpan.kt @@ -0,0 +1,37 @@ +/* + * 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 android.graphics.Canvas +import android.text.Layout +import com.facebook.react.common.annotations.UnstableReactNativeAPI + +/** + * A span which draws an animated effect on top of text. Each frame, [onDraw] is called with the + * time since the last frame. Return true to request another frame, false to stop animating. + */ +@UnstableReactNativeAPI +public interface AnimatedEffectSpan : StatefulSpan { + /** + * Called each frame to draw an animated effect on top of text. + * + * @param start the start offset of this span within the text + * @param end the end offset of this span within the text + * @param canvas the canvas to draw on + * @param layout the text layout + * @param deltaNanos nanoseconds since the last frame, or 0 on the first frame + * @return true to request another frame, false to stop animating + */ + public fun onDraw( + start: Int, + end: Int, + canvas: Canvas, + layout: Layout, + deltaNanos: Long, + ): Boolean +}