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..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,14 +47,19 @@ 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) { 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 +69,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() } } @@ -94,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) @@ -146,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 + } + } + } } } @@ -393,5 +443,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/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 +} 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 +}