Skip to content
Merged
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 @@ -48,7 +48,9 @@ import kotlin.math.roundToInt
@DoNotStrip
internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), ReactCompoundView {

private var clickableSpans: List<ClickableSpan> = 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

Expand All @@ -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()
Expand Down Expand Up @@ -105,11 +106,11 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
fun recycleView() {
BackgroundStyleApplicator.reset(this)
overflow = Overflow.VISIBLE
clickableSpans = emptyList()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old code explicitly cleared clickableSpans = emptyList() here. Consider adding hasClickableSpansCache = null for defensive consistency — if a recycled view ever receives a touch before a new preparedLayout is set, the cache would still hold the stale value from the previous text.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andrewdacenko done in e5873ce: recycleView() now clears hasClickableSpansCache too, and I added a test for it. Thanks!

selection = null
selectionColor = null
lastFrameTimeNanos = 0L
preparedLayout = null
hasClickableSpansCache = null
}

override fun onVisibilityChanged(changedView: View, visibility: Int) {
Expand Down Expand Up @@ -277,7 +278,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
}

// Existing ClickableSpan handling
if (clickableSpans.isEmpty()) {
if (!hasClickableSpans()) {
return super.onTouchEvent(event)
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<ClickableSpan> {
if (text !is Spanned) {
return emptyList()
}

val spans = ArrayList<ClickableSpan>()
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading