Skip to content

Commit 2a0f595

Browse files
andrewdacenkometa-codesync[bot]
authored andcommitted
Add TouchableSpan interface for position-aware touch on text spans (#56709)
Summary: Pull Request resolved: #56709 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 fbshipit-source-id: 434f9e9e4f2fd5eeb2b535852efdbdfb48ad7734
1 parent fa05b8c commit 2a0f595

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.facebook.react.views.text.internal.span.AnimatedEffectSpan
3333
import com.facebook.react.views.text.internal.span.CanvasEffectSpan
3434
import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan
3535
import com.facebook.react.views.text.internal.span.ReactLinkSpan
36+
import com.facebook.react.views.text.internal.span.TouchableSpan
3637
import kotlin.collections.ArrayList
3738
import kotlin.math.roundToInt
3839

@@ -230,20 +231,45 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
230231
invalidate()
231232
}
232233

234+
@OptIn(UnstableReactNativeAPI::class)
233235
override fun onTouchEvent(event: MotionEvent): Boolean {
234-
if (!isEnabled || clickableSpans.isEmpty()) {
236+
if (!isEnabled) {
235237
return super.onTouchEvent(event)
236238
}
237239

238240
val action = event.actionMasked
239241
if (action == MotionEvent.ACTION_CANCEL) {
242+
// Forward ACTION_CANCEL to all TouchableSpans so they can reset pressed/animation state
243+
val spanned = text as? Spanned
244+
for (span in spanned?.getSpans(0, spanned.length, TouchableSpan::class.java).orEmpty()) {
245+
span.onTouchEvent(action, 0f, 0f)
246+
}
240247
clearSelection()
241248
return false
242249
}
243250

244251
val x = event.x.toInt()
245252
val y = event.y.toInt()
246253

254+
// Handle TouchableSpan (e.g., spoiler text) — independent of ClickableSpan.
255+
// Only consume the event if the span actually handled it (e.g., spoiler not yet
256+
// dismissed). If it returns false, fall through to ClickableSpan handling so that
257+
// links under dismissed spoiler text remain tappable.
258+
val touchableSpan = getSpanInCoords(x, y, TouchableSpan::class.java)
259+
if (touchableSpan != null) {
260+
val layoutX = event.x - paddingLeft
261+
val layoutY = event.y - paddingTop - (preparedLayout?.verticalOffset ?: 0f)
262+
if (touchableSpan.onTouchEvent(action, layoutX, layoutY)) {
263+
invalidate()
264+
return true
265+
}
266+
}
267+
268+
// Existing ClickableSpan handling
269+
if (clickableSpans.isEmpty()) {
270+
return super.onTouchEvent(event)
271+
}
272+
247273
val clickableSpan = getSpanInCoords(x, y, ClickableSpan::class.java)
248274

249275
if (clickableSpan == null) {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 com.facebook.react.common.annotations.UnstableReactNativeAPI
11+
12+
/**
13+
* Interface for spans that receive touch events from [PreparedLayoutTextView]. Unlike
14+
* [ClickableSpan] which only provides an onClick callback with no position information,
15+
* TouchableSpan receives layout-relative coordinates, enabling position-aware interactions such as
16+
* dismiss animations originating from the tap point.
17+
*/
18+
@UnstableReactNativeAPI
19+
public interface TouchableSpan {
20+
/**
21+
* Called when a touch event occurs on text covered by this span.
22+
*
23+
* @param action the [MotionEvent] action (e.g. [MotionEvent.ACTION_DOWN], [ACTION_UP])
24+
* @param layoutX x coordinate relative to the text layout
25+
* @param layoutY y coordinate relative to the text layout
26+
* @return true if the event was consumed
27+
*/
28+
public fun onTouchEvent(action: Int, layoutX: Float, layoutY: Float): Boolean
29+
}

0 commit comments

Comments
 (0)