Skip to content

Commit 71a0d5d

Browse files
andrewdacenkometa-codesync[bot]
authored andcommitted
Add support for animated effects on spans of text (facebook#56702)
Summary: Pull Request resolved: facebook#56702 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 fbshipit-source-id: da2015c1b49d122c522dae5c49f6c83ca1979daf
1 parent 97fa2a4 commit 71a0d5d

2 files changed

Lines changed: 82 additions & 0 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@ import android.text.Spanned
1818
import android.text.style.ClickableSpan
1919
import android.view.KeyEvent
2020
import android.view.MotionEvent
21+
import android.view.View
2122
import android.view.ViewGroup
2223
import androidx.annotation.ColorInt
2324
import androidx.annotation.DoNotInline
2425
import androidx.annotation.RequiresApi
2526
import androidx.core.view.ViewCompat
2627
import com.facebook.proguard.annotations.DoNotStrip
28+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
2729
import com.facebook.react.uimanager.BackgroundStyleApplicator
2830
import com.facebook.react.uimanager.ReactCompoundView
2931
import com.facebook.react.uimanager.style.Overflow
32+
import com.facebook.react.views.text.internal.span.AnimatedEffectSpan
3033
import com.facebook.react.views.text.internal.span.DrawCommandSpan
3134
import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan
3235
import com.facebook.react.views.text.internal.span.ReactLinkSpan
@@ -44,6 +47,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
4447

4548
private var clickableSpans: List<ClickableSpan> = emptyList()
4649
private var selection: TextSelection? = null
50+
private var lastFrameTimeNanos: Long = 0L
4751

4852
var preparedLayout: PreparedLayout? = null
4953
set(value) {
@@ -99,9 +103,18 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
99103
clickableSpans = emptyList()
100104
selection = null
101105
selectionColor = null
106+
lastFrameTimeNanos = 0L
102107
preparedLayout = null
103108
}
104109

110+
override fun onVisibilityChanged(changedView: View, visibility: Int) {
111+
super.onVisibilityChanged(changedView, visibility)
112+
if (visibility != VISIBLE) {
113+
lastFrameTimeNanos = 0L
114+
}
115+
}
116+
117+
@OptIn(UnstableReactNativeAPI::class)
105118
override fun onDraw(canvas: Canvas) {
106119
if (overflow != Overflow.VISIBLE) {
107120
BackgroundStyleApplicator.clipToPaddingBox(this, canvas)
@@ -151,6 +164,38 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
151164
)
152165
}
153166
}
167+
168+
if (spanned != null) {
169+
val animatedEffectSpans =
170+
spanned.getSpans(0, spanned.length, AnimatedEffectSpan::class.java)
171+
172+
if (animatedEffectSpans.isNotEmpty()) {
173+
val now = System.nanoTime()
174+
val deltaNanos = if (lastFrameTimeNanos == 0L) 0L else now - lastFrameTimeNanos
175+
lastFrameTimeNanos = now
176+
177+
var needsNextFrame = false
178+
for (span in animatedEffectSpans) {
179+
if (
180+
span.onDraw(
181+
spanned.getSpanStart(span),
182+
spanned.getSpanEnd(span),
183+
canvas,
184+
layout,
185+
deltaNanos,
186+
)
187+
) {
188+
needsNextFrame = true
189+
}
190+
}
191+
192+
if (needsNextFrame) {
193+
postInvalidateOnAnimation()
194+
} else {
195+
lastFrameTimeNanos = 0L
196+
}
197+
}
198+
}
154199
}
155200
}
156201

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.text.internal.span
9+
10+
import android.graphics.Canvas
11+
import android.text.Layout
12+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
13+
14+
/**
15+
* A span which draws an animated effect on top of text. Each frame, [onDraw] is called with the
16+
* time since the last frame. Return true to request another frame, false to stop animating.
17+
*/
18+
@UnstableReactNativeAPI
19+
public interface AnimatedEffectSpan : StatefulSpan {
20+
/**
21+
* Called each frame to draw an animated effect on top of text.
22+
*
23+
* @param start the start offset of this span within the text
24+
* @param end the end offset of this span within the text
25+
* @param canvas the canvas to draw on
26+
* @param layout the text layout
27+
* @param deltaNanos nanoseconds since the last frame, or 0 on the first frame
28+
* @return true to request another frame, false to stop animating
29+
*/
30+
public fun onDraw(
31+
start: Int,
32+
end: Int,
33+
canvas: Canvas,
34+
layout: Layout,
35+
deltaNanos: Long,
36+
): Boolean
37+
}

0 commit comments

Comments
 (0)