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 86b91ed4165..e1ae6ac7ff6 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 @@ -48,7 +48,9 @@ import kotlin.math.roundToInt @DoNotStrip internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), ReactCompoundView { - private var clickableSpans: List = emptyList() + // Whether the text has a ClickableSpan. Only touch handling needs it, so it is found on first use + // rather than on every text update; null until then. + private var hasClickableSpansCache: Boolean? = null private var selection: TextSelection? = null private var lastFrameTimeNanos: Long = 0L @@ -72,8 +74,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } } - clickableSpans = - effectiveValue?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList() + hasClickableSpansCache = null field = effectiveValue invalidate() @@ -105,11 +106,11 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re fun recycleView() { BackgroundStyleApplicator.reset(this) overflow = Overflow.VISIBLE - clickableSpans = emptyList() selection = null selectionColor = null lastFrameTimeNanos = 0L preparedLayout = null + hasClickableSpansCache = null } override fun onVisibilityChanged(changedView: View, visibility: Int) { @@ -277,7 +278,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } // Existing ClickableSpan handling - if (clickableSpans.isEmpty()) { + if (!hasClickableSpans()) { return super.onTouchEvent(event) } @@ -301,6 +302,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re return true } + private fun hasClickableSpans(): Boolean = + hasClickableSpansCache + ?: hasClickableSpan(preparedLayout?.layout?.text).also { hasClickableSpansCache = it } + private fun findRootView(): RootView? { var p: ViewParent? = parent while (p != null) { @@ -413,7 +418,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re direction: Int, previouslyFocusedRect: Rect?, ) { - if (clickableSpans.isNotEmpty() && !gainFocus) { + if (!gainFocus && selection != null) { clearSelection() } super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) @@ -475,21 +480,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re private companion object { private val selectionPaint = Paint() - private fun filterClickableSpans(text: CharSequence): List { - if (text !is Spanned) { - return emptyList() - } - - val spans = ArrayList() - var i = 0 - while (i < text.length) { - val next = text.nextSpanTransition(i, text.length, ClickableSpan::class.java) - spans.addAll(text.getSpans(i, next, ClickableSpan::class.java)) - i = next - } - - return spans - } + private fun hasClickableSpan(text: CharSequence?): Boolean = + text is Spanned && + text.isNotEmpty() && + text.getSpans(0, text.length, ClickableSpan::class.java).isNotEmpty() /** * If the layout contains [StatefulSpan]s, returns a new [PreparedLayout] whose spannable has diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt new file mode 100644 index 00000000000..b327e609555 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt @@ -0,0 +1,141 @@ +/* + * 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 + +import android.annotation.SuppressLint +import android.graphics.text.LineBreakConfig +import android.os.SystemClock +import android.text.Layout +import android.text.Spannable +import android.text.SpannableString +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.view.MotionEvent +import android.view.View +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.common.mapbuffer.WritableMapBuffer +import com.facebook.yoga.YogaMeasureMode +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Tapping a [ClickableSpan] in a [PreparedLayoutTextView] clicks it, for the current text only. */ +@OptIn(UnstableReactNativeAPI::class) +@RunWith(RobolectricTestRunner::class) +@SuppressLint("NewApi") +@Config(sdk = [34]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PreparedLayoutTextViewClickableSpanTest { + + private val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + @Test + fun `tapping a clickable span clicks it`() { + val span = CountingClickableSpan() + view.preparedLayout = preparedLayout(textWith(span)) + + assertThat(tap()).isTrue() + assertThat(span.clicks).isEqualTo(1) + } + + @Test + fun `tapping text without clickable spans does not click`() { + val span = CountingClickableSpan() + view.preparedLayout = preparedLayout(textWith(span)) + tap() + + view.preparedLayout = preparedLayout(SpannableString(TEXT)) + + assertThat(tap()).isFalse() + assertThat(span.clicks).isEqualTo(1) + } + + @Test + fun `tapping a clickable span in new text clicks it`() { + view.preparedLayout = preparedLayout(SpannableString(TEXT)) + tap() + + val span = CountingClickableSpan() + view.preparedLayout = preparedLayout(textWith(span)) + + assertThat(tap()).isTrue() + assertThat(span.clicks).isEqualTo(1) + } + + @Test + fun `a recycled view does not click spans of its previous text`() { + val span = CountingClickableSpan() + view.preparedLayout = preparedLayout(textWith(span)) + tap() + + view.recycleView() + + assertThat(tap()).isFalse() + assertThat(span.clicks).isEqualTo(1) + } + + /** Sends a down and an up at the start of the text; returns whether the up was handled. */ + private fun tap(): Boolean { + val time = SystemClock.uptimeMillis() + val down = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 5f, 5f, 0) + val up = MotionEvent.obtain(time, time, MotionEvent.ACTION_UP, 5f, 5f, 0) + view.onTouchEvent(down) + val handled = view.onTouchEvent(up) + down.recycle() + up.recycle() + return handled + } + + private fun textWith(span: ClickableSpan): SpannableString = + SpannableString(TEXT).apply { + setSpan(span, 0, TEXT.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE) + } + + private fun preparedLayout(text: SpannableString): PreparedLayout = + PreparedLayout( + TextLayoutManager.createLayout( + text, + /* boring = */ null, + /* width = */ 400f, + YogaMeasureMode.EXACTLY, + /* includeFontPadding = */ false, + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, + /* lineBreakStyle = */ LineBreakConfig.LINE_BREAK_STYLE_NONE, + /* lineBreakWordStyle = */ LineBreakConfig.LINE_BREAK_WORD_STYLE_NONE, + Layout.Alignment.ALIGN_NORMAL, + /* justificationMode = */ 0, + /* ellipsizeMode = */ null, + /* maxNumberOfLines = */ 0, + TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f }, + ), + /* maximumNumberOfLines = */ 0, + /* verticalOffset = */ 0f, + /* reactTags = */ IntArray(0), + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* justificationMode = */ 0, + baseTextAttributes = TextAttributeProps.fromMapBuffer(WritableMapBuffer()), + /* sourceText = */ text, + ) + + private class CountingClickableSpan : ClickableSpan() { + var clicks = 0 + + override fun onClick(widget: View) { + clicks++ + } + } + + private companion object { + const val TEXT = "Tap this text" + } +}