From 42bc4a22e8c896b3748eb247f9d2dba6093a44d1 Mon Sep 17 00:00:00 2001 From: Andrew Datsenko Date: Thu, 7 May 2026 07:33:38 -0700 Subject: [PATCH] Add TouchableSpan interface for position-aware touch on text spans (#56709) Summary: Changelog: [Internal] Introduces `TouchableSpan`, an interface for spans that receive full `MotionEvent` touch events from `PreparedLayoutTextView`. Unlike `ClickableSpan` which only provides an `onClick` callback with no position information, `TouchableSpan` receives the full `MotionEvent`, enabling position-aware interactions such as dismiss animations originating from the tap point. Reviewed By: Abbondanzo Differential Revision: D97417356 --- .../views/text/PreparedLayoutTextView.kt | 28 +++++++++++++++++- .../views/text/internal/span/TouchableSpan.kt | 29 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/TouchableSpan.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 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 +}