From cec9821089e4851b0f375279e05e05440d36c3c8 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Wed, 6 May 2026 05:42:34 -0700 Subject: [PATCH 1/2] Add support for animated 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 - Extends `StatefulSpan` — animated effects hold per-view mutable state (e.g. particle positions) that must be cloned when layouts are shared - Does not implement `UpdateAppearance` — animated effects don't affect text measurement or paint state - 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 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 e3a0c122e4a..9acc47f4de1 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 00000000000..24b985f1bfd --- /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 +} From 2711d3abe6742bdd4657b415e25b7b211181592d Mon Sep 17 00:00:00 2001 From: Andrew Datsenko Date: Wed, 6 May 2026 08:28:26 -0700 Subject: [PATCH 2/2] Rename DrawCommandSpan to CanvasEffectSpan and remove UpdateAppearance (#56705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56705 Changelog: [Internal] Renames `DrawCommandSpan` to `CanvasEffectSpan` for consistency with the new `AnimatedEffectSpan`, and removes the `UpdateAppearance` interface. `UpdateAppearance` is a marker interface that tells Android's text layout system the span affects text appearance (triggering re-measurement). Since `CanvasEffectSpan` only draws on top of text during `PreparedLayoutTextView`'s draw pass and never modifies paint state or text measurement, implementing `UpdateAppearance` was incorrect — the same reasoning applied when designing `AnimatedEffectSpan` without it. Reviewed By: mdvacca Differential Revision: D97399655 --- .../react/views/text/PreparedLayoutTextView.kt | 10 +++++----- .../span/{DrawCommandSpan.kt => CanvasEffectSpan.kt} | 7 +++---- 2 files changed, 8 insertions(+), 9 deletions(-) rename packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/{DrawCommandSpan.kt => CanvasEffectSpan.kt} (73%) 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 9acc47f4de1..9a2e1c7b7a9 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 @@ -30,7 +30,7 @@ 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.CanvasEffectSpan import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan import com.facebook.react.views.text.internal.span.ReactLinkSpan import kotlin.collections.ArrayList @@ -134,11 +134,11 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } val spanned = text as? Spanned - val drawCommandSpans = - spanned?.getSpans(0, spanned.length, DrawCommandSpan::class.java) ?: emptyArray() + val canvasEffectSpans = + spanned?.getSpans(0, spanned.length, CanvasEffectSpan::class.java) ?: emptyArray() if (spanned != null) { - for (span in drawCommandSpans) { + for (span in canvasEffectSpans) { span.onPreDraw( spanned.getSpanStart(span), spanned.getSpanEnd(span), @@ -155,7 +155,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } if (spanned != null) { - for (span in drawCommandSpans) { + for (span in canvasEffectSpans) { span.onDraw( spanned.getSpanStart(span), spanned.getSpanEnd(span), diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DrawCommandSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CanvasEffectSpan.kt similarity index 73% rename from packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DrawCommandSpan.kt rename to packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CanvasEffectSpan.kt index 21d4f8cd30b..1ec5761780d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DrawCommandSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CanvasEffectSpan.kt @@ -9,13 +9,12 @@ package com.facebook.react.views.text.internal.span import android.graphics.Canvas import android.text.Layout -import android.text.style.UpdateAppearance /** - * May be overridden to implement character styles which are applied by [PreparedLayoutTextView] - * during the drawing of text, against the underlying Android canvas + * A span which draws a static effect on top of text. [onPreDraw] and [onDraw] hooks are called + * during [PreparedLayoutTextView] drawing, providing glyph layout information for custom rendering. */ -public abstract class DrawCommandSpan : UpdateAppearance, ReactSpan { +public abstract class CanvasEffectSpan { /** * Called before the text is drawn. This happens after the Paragraph component has drawn its * background, but may be called before text spans with their own background color are drawn.