Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ import com.facebook.react.uimanager.BackgroundStyleApplicator
import com.facebook.react.uimanager.ReactCompoundView
import com.facebook.react.uimanager.style.Overflow
import com.facebook.react.views.text.internal.span.AnimatedEffectSpan
import com.facebook.react.views.text.internal.span.DrawCommandSpan
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

Expand Down Expand Up @@ -134,11 +135,11 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
}

val spanned = text as? Spanned
val drawCommandSpans =
spanned?.getSpans(0, spanned.length, DrawCommandSpan::class.java) ?: emptyArray()
val canvasEffectSpans =
spanned?.getSpans(0, spanned.length, CanvasEffectSpan::class.java) ?: emptyArray()

if (spanned != null) {
for (span in drawCommandSpans) {
for (span in canvasEffectSpans) {
span.onPreDraw(
spanned.getSpanStart(span),
spanned.getSpanEnd(span),
Expand All @@ -155,7 +156,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
}

if (spanned != null) {
for (span in drawCommandSpans) {
for (span in canvasEffectSpans) {
span.onDraw(
spanned.getSpanStart(span),
spanned.getSpanEnd(span),
Expand Down Expand Up @@ -230,8 +231,9 @@ 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)
}

Expand All @@ -244,6 +246,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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ package com.facebook.react.views.text.internal.span

import android.graphics.Canvas
import android.text.Layout
import android.text.style.UpdateAppearance

/**
* May be overridden to implement character styles which are applied by [PreparedLayoutTextView]
* during the drawing of text, against the underlying Android canvas
* A span which draws a static effect on top of text. [onPreDraw] and [onDraw] hooks are called
* during [PreparedLayoutTextView] drawing, providing glyph layout information for custom rendering.
*/
public abstract class DrawCommandSpan : UpdateAppearance, ReactSpan {
public abstract class CanvasEffectSpan {
/**
* Called before the text is drawn. This happens after the Paragraph component has drawn its
* background, but may be called before text spans with their own background color are drawn.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Loading