Skip to content

Commit ab8b40d

Browse files
andrewdacenkometa-codesync[bot]
authored andcommitted
Add TextEffect component and registry for custom text span effects (#56720)
Summary: Pull Request resolved: #56720 Introduces a generic TextEffect system that lets apps register custom text span effects without modifying React Native core. This can serve for use cases like `react-native-live-markdown`, or product specific effects. The implementation is pretty dumb right now. It's just a marker, where we can add some JSON serializable data, to be serialized during Spannable creation on JS side MapBuffer. **JS API:** ```js import requireNativeTextEffect from 'react-native/Libraries/Text/requireNativeTextEffect'; const Spoiler = requireNativeTextEffect<{}>('RCTSpoiler'); <Text>Normal <Spoiler>hidden text</Spoiler></Text> ``` **Android registration** (via FabricUIManager): ```kotlin val fabricUIManager = UIManagerHelper.getUIManager( context, UIManagerType.FABRIC) as? FabricUIManager fabricUIManager?.textEffectRegistry?.register("RCTSpoiler") { props -> MyCustomSpan() } ``` Changelog: [Internal] Reviewed By: javache Differential Revision: D98812222 fbshipit-source-id: 7000a0452b7592cdc2b26e7eaa37ec95736efce4
1 parent 497177f commit ab8b40d

30 files changed

Lines changed: 746 additions & 12 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import type {HostComponent} from '../../src/private/types/HostComponent';
12+
13+
import * as NativeComponentRegistry from '../NativeComponent/NativeComponentRegistry';
14+
import * as React from 'react';
15+
16+
type NativeTextEffectProps = Readonly<{
17+
effectName?: ?string,
18+
effectProps?: ?Readonly<{+[string]: unknown}>,
19+
children?: React.Node,
20+
}>;
21+
22+
const NativeTextEffect: HostComponent<NativeTextEffectProps> =
23+
NativeComponentRegistry.get<NativeTextEffectProps>('RCTTextEffect', () => ({
24+
validAttributes: {
25+
effectName: true,
26+
effectProps: true,
27+
},
28+
uiViewClassName: 'RCTTextEffect',
29+
}));
30+
31+
export default NativeTextEffect;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import Text from './Text';
12+
import NativeTextEffect from './TextEffectNativeComponent';
13+
import * as React from 'react';
14+
15+
export default function requireNativeTextEffect<P>(
16+
name: string,
17+
): React.ComponentType<{...P, children: React.Node}> {
18+
component TextEffect(...props: {...P, children: React.Node}) {
19+
const {children, ...effectProps} = props;
20+
return (
21+
<NativeTextEffect effectName={name} effectProps={effectProps}>
22+
<Text>{children}</Text>
23+
</NativeTextEffect>
24+
);
25+
}
26+
TextEffect.displayName = `TextEffect(${name})`;
27+
return TextEffect;
28+
}

packages/react-native/ReactAndroid/api/ReactAndroid.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6141,6 +6141,7 @@ public final class com/facebook/react/views/text/TextAttributeProps {
61416141
public static final field TA_KEY_TEXT_DECORATION_COLOR I
61426142
public static final field TA_KEY_TEXT_DECORATION_LINE I
61436143
public static final field TA_KEY_TEXT_DECORATION_STYLE I
6144+
public static final field TA_KEY_TEXT_EFFECTS I
61446145
public static final field TA_KEY_TEXT_SHADOW_COLOR I
61456146
public static final field TA_KEY_TEXT_SHADOW_OFFSET_DX I
61466147
public static final field TA_KEY_TEXT_SHADOW_OFFSET_DY I
@@ -6209,6 +6210,9 @@ public final class com/facebook/react/views/text/TextAttributes {
62096210
public fun toString ()Ljava/lang/String;
62106211
}
62116212

6213+
public final class com/facebook/react/views/text/TextEffectRegistry$Companion {
6214+
}
6215+
62126216
public abstract interface class com/facebook/react/views/textinput/ContentSizeWatcher {
62136217
public abstract fun onLayout ()V
62146218
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
import com.facebook.react.views.text.PreparedLayout;
9494
import com.facebook.react.views.text.ReactTextViewManager;
9595
import com.facebook.react.views.text.ReactTextViewManagerCallback;
96+
import com.facebook.react.views.text.TextEffectRegistry;
9697
import com.facebook.react.views.text.TextLayoutManager;
9798
import java.util.ArrayList;
9899
import java.util.HashMap;
@@ -179,6 +180,8 @@ public class FabricUIManager
179180
private final MountItemDispatcher mMountItemDispatcher;
180181
private final ViewManagerRegistry mViewManagerRegistry;
181182

183+
private final TextEffectRegistry mTextEffectRegistry = new TextEffectRegistry();
184+
182185
private final BatchEventDispatchedListener mBatchEventDispatchedListener;
183186

184187
private final List<UIManagerListener> mListeners = new CopyOnWriteArrayList<>();
@@ -553,7 +556,8 @@ private NativeArray measureLines(
553556
PixelUtil.toPixelFromDIP(height),
554557
textViewManager instanceof ReactTextViewManagerCallback
555558
? (ReactTextViewManagerCallback) textViewManager
556-
: null);
559+
: null,
560+
mTextEffectRegistry);
557561
}
558562

559563
public int getColor(int surfaceId, String[] resourcePaths) {
@@ -641,7 +645,8 @@ public long measureText(
641645
textViewManager instanceof ReactTextViewManagerCallback
642646
? (ReactTextViewManagerCallback) textViewManager
643647
: null,
644-
attachmentsPositions);
648+
attachmentsPositions,
649+
mTextEffectRegistry);
645650
}
646651

647652
@AnyThread
@@ -666,7 +671,8 @@ public PreparedLayout prepareTextLayout(
666671
getYogaMeasureMode(minHeight, maxHeight),
667672
textViewManager instanceof ReactTextViewManagerCallback
668673
? (ReactTextViewManagerCallback) textViewManager
669-
: null);
674+
: null,
675+
mTextEffectRegistry);
670676
}
671677

672678
@AnyThread
@@ -700,6 +706,11 @@ public float[] measurePreparedLayout(
700706
getYogaMeasureMode(minHeight, maxHeight));
701707
}
702708

709+
@UnstableReactNativeAPI
710+
public TextEffectRegistry getTextEffectRegistry() {
711+
return mTextEffectRegistry;
712+
}
713+
703714
/**
704715
* @param surfaceId {@link int} surface ID
705716
* @param defaultTextInputPadding {@link float[]} output parameter will contain the default theme

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import android.view.KeyEvent
2020
import android.view.MotionEvent
2121
import android.view.View
2222
import android.view.ViewGroup
23+
import android.view.ViewParent
2324
import androidx.annotation.ColorInt
2425
import androidx.annotation.DoNotInline
2526
import androidx.annotation.RequiresApi
@@ -28,6 +29,7 @@ import com.facebook.proguard.annotations.DoNotStrip
2829
import com.facebook.react.common.annotations.UnstableReactNativeAPI
2930
import com.facebook.react.uimanager.BackgroundStyleApplicator
3031
import com.facebook.react.uimanager.ReactCompoundView
32+
import com.facebook.react.uimanager.RootView
3133
import com.facebook.react.uimanager.style.Overflow
3234
import com.facebook.react.views.text.internal.span.AnimatedEffectSpan
3335
import com.facebook.react.views.text.internal.span.CanvasEffectSpan
@@ -260,6 +262,13 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
260262
val layoutX = event.x - paddingLeft
261263
val layoutY = event.y - paddingTop - (preparedLayout?.verticalOffset ?: 0f)
262264
if (touchableSpan.onTouchEvent(action, layoutX, layoutY)) {
265+
if (action == MotionEvent.ACTION_DOWN) {
266+
// Returning true from onTouchEvent stops Android's onClickListener path on parents,
267+
// but RN's gesture responder runs at the JS layer and would still let an ancestor
268+
// <Pressable> fire onPress on this gesture. Tell the React root we're taking over so
269+
// it cancels in-flight JS responder tracking — same hook ScrollView uses on intercept.
270+
findRootView()?.onChildStartedNativeGesture(this, event)
271+
}
263272
invalidate()
264273
return true
265274
}
@@ -290,6 +299,15 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
290299
return true
291300
}
292301

302+
private fun findRootView(): RootView? {
303+
var p: ViewParent? = parent
304+
while (p != null) {
305+
if (p is RootView) return p
306+
p = p.parent
307+
}
308+
return null
309+
}
310+
293311
private fun <T> getSpanInCoords(x: Int, y: Int, clazz: Class<T>): T? {
294312
val offset = getTextOffsetAt(x, y)
295313
if (offset < 0) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ public constructor(
160160
view.context.assets,
161161
attributedString,
162162
reactTextViewManagerCallback,
163+
TextEffectRegistry.current,
163164
)
164165
view.setSpanned(spanned)
165166

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ public class TextAttributeProps private constructor() {
100100
public var role: ReactAccessibilityDelegate.Role? = null
101101
private set
102102

103+
internal data class TextEffectEntry(val name: String, val props: String?)
104+
105+
internal var textEffects: List<TextEffectEntry> = emptyList()
106+
private set
107+
103108
public var fontStyle: Int = ReactConstants.UNSET
104109
private set
105110

@@ -375,6 +380,9 @@ public class TextAttributeProps private constructor() {
375380
public const val TA_KEY_ROLE: Int = 26
376381
public const val TA_KEY_TEXT_TRANSFORM: Int = 27
377382
public const val TA_KEY_MAX_FONT_SIZE_MULTIPLIER: Int = 29
383+
public const val TA_KEY_TEXT_EFFECTS: Int = 30
384+
private const val TE_KEY_NAME: Int = 0
385+
private const val TE_KEY_PROPS: Int = 1
378386

379387
public const val UNSET: Int = -1
380388

@@ -429,6 +437,22 @@ public class TextAttributeProps private constructor() {
429437
TA_KEY_TEXT_TRANSFORM -> result.setTextTransform(entry.stringValue)
430438
TA_KEY_MAX_FONT_SIZE_MULTIPLIER ->
431439
result.maxFontSizeMultiplier = entry.doubleValue.toFloat()
440+
TA_KEY_TEXT_EFFECTS -> {
441+
val effectsMap = entry.mapBufferValue
442+
val list = mutableListOf<TextEffectEntry>()
443+
for (j in 0 until effectsMap.count) {
444+
val effectMap = effectsMap.getMapBuffer(j)
445+
list.add(
446+
TextEffectEntry(
447+
name = effectMap.getString(TE_KEY_NAME),
448+
props =
449+
if (effectMap.contains(TE_KEY_PROPS)) effectMap.getString(TE_KEY_PROPS)
450+
else null,
451+
)
452+
)
453+
}
454+
result.textEffects = list
455+
}
432456
}
433457
}
434458

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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
9+
10+
import com.facebook.common.logging.FLog
11+
import com.facebook.react.bridge.ReadableMap
12+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
13+
import com.facebook.react.views.text.internal.span.TextEffectSpan
14+
import java.util.concurrent.ConcurrentHashMap
15+
16+
@UnstableReactNativeAPI
17+
public class TextEffectRegistry {
18+
private val factories = ConcurrentHashMap<String, TextEffectSpanFactory>()
19+
20+
public fun register(name: String, factory: TextEffectSpanFactory) {
21+
factories[name] = factory
22+
current = this
23+
}
24+
25+
public fun unregister(name: String) {
26+
factories.remove(name)
27+
}
28+
29+
internal fun createSpan(name: String, props: ReadableMap?): TextEffectSpan? {
30+
val factory = factories[name] ?: return null
31+
return try {
32+
factory.createSpan(props)
33+
} catch (t: Throwable) {
34+
// A throwing factory (e.g. invalid color prop) must not break the entire text render path
35+
// for an unrelated paragraph or for subsequent renders. Skip this span and keep going.
36+
FLog.e(TAG, "TextEffectSpanFactory '$name' threw — skipping span", t)
37+
null
38+
}
39+
}
40+
41+
public companion object {
42+
private const val TAG = "TextEffectRegistry"
43+
@JvmField @Volatile public var current: TextEffectRegistry? = null
44+
}
45+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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
9+
10+
import com.facebook.react.bridge.ReadableMap
11+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
12+
import com.facebook.react.views.text.internal.span.TextEffectSpan
13+
14+
@UnstableReactNativeAPI
15+
public fun interface TextEffectSpanFactory {
16+
public fun createSpan(props: ReadableMap?): TextEffectSpan
17+
}

0 commit comments

Comments
 (0)