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 9a2e1c7b7a9..427e71e059f 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 @@ -33,6 +33,7 @@ import com.facebook.react.views.text.internal.span.AnimatedEffectSpan 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 com.facebook.react.views.text.internal.span.TouchableSpan import kotlin.collections.ArrayList import kotlin.math.roundToInt @@ -230,13 +231,19 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re invalidate() } + @OptIn(UnstableReactNativeAPI::class) override fun onTouchEvent(event: MotionEvent): Boolean { - if (!isEnabled || clickableSpans.isEmpty()) { + if (!isEnabled) { return super.onTouchEvent(event) } val action = event.actionMasked if (action == MotionEvent.ACTION_CANCEL) { + // Forward ACTION_CANCEL to all TouchableSpans so they can reset pressed/animation state + val spanned = text as? Spanned + for (span in spanned?.getSpans(0, spanned.length, TouchableSpan::class.java).orEmpty()) { + span.onTouchEvent(action, 0f, 0f) + } clearSelection() return false } @@ -244,6 +251,25 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val x = event.x.toInt() val y = event.y.toInt() + // Handle TouchableSpan (e.g., spoiler text) — independent of ClickableSpan. + // Only consume the event if the span actually handled it (e.g., spoiler not yet + // dismissed). If it returns false, fall through to ClickableSpan handling so that + // links under dismissed spoiler text remain tappable. + val touchableSpan = getSpanInCoords(x, y, TouchableSpan::class.java) + if (touchableSpan != null) { + val layoutX = event.x - paddingLeft + val layoutY = event.y - paddingTop - (preparedLayout?.verticalOffset ?: 0f) + if (touchableSpan.onTouchEvent(action, layoutX, layoutY)) { + invalidate() + return true + } + } + + // Existing ClickableSpan handling + if (clickableSpans.isEmpty()) { + return super.onTouchEvent(event) + } + val clickableSpan = getSpanInCoords(x, y, ClickableSpan::class.java) if (clickableSpan == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/TouchableSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/TouchableSpan.kt new file mode 100644 index 00000000000..b1fadde2140 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/TouchableSpan.kt @@ -0,0 +1,29 @@ +/* + * 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 + +/** + * Interface for spans that receive touch events from [PreparedLayoutTextView]. Unlike + * [ClickableSpan] which only provides an onClick callback with no position information, + * TouchableSpan receives layout-relative coordinates, enabling position-aware interactions such as + * dismiss animations originating from the tap point. + */ +@UnstableReactNativeAPI +public interface TouchableSpan { + /** + * Called when a touch event occurs on text covered by this span. + * + * @param action the [MotionEvent] action (e.g. [MotionEvent.ACTION_DOWN], [ACTION_UP]) + * @param layoutX x coordinate relative to the text layout + * @param layoutY y coordinate relative to the text layout + * @return true if the event was consumed + */ + public fun onTouchEvent(action: Int, layoutX: Float, layoutY: Float): Boolean +}