Skip to content

Commit 36ae029

Browse files
NickGerlemanfacebook-github-bot
authored andcommitted
Fix Facsimile Hit Testing Bugs (facebook#52065)
Summary: Pull Request resolved: facebook#52065 1. We can crash when tapping at the boundary between two spans. Previous ReactTextView had some custom heuristic for overlapping ReactTags, assuming they could be nested, which never happens (We only have a single tag per AttributedString fragment), but spans may overlap at a single character, where one is meant to be exclusive, and the other inclusive. We add logic for that. 2. We don't incorporate the offset of the text layout within the view for hit testing, needed for padding or `textAlignVertical`. Changelog: [Internal] Reviewed By: joevilches Differential Revision: D76764051
1 parent 3ae9328 commit 36ae029

File tree

1 file changed

+38
-32
lines changed

1 file changed

+38
-32
lines changed

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

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
162162
val x = event.x.toInt()
163163
val y = event.y.toInt()
164164

165-
val clickableSpan = getClickableSpanInCoords(x, y)
165+
val clickableSpan = getSpanInCoords(x, y, ClickableSpan::class.java)
166166

167167
if (clickableSpan == null) {
168168
clearSelection()
@@ -182,33 +182,53 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
182182
return true
183183
}
184184

185-
/**
186-
* Get the clickable span that is at the exact coordinates
187-
*
188-
* @param x x-position of the click
189-
* @param y y-position of the click
190-
* @return a clickable span that's located where the click occurred, or: `null` if no clickable
191-
* span was located there
192-
*/
193-
private fun getClickableSpanInCoords(x: Int, y: Int): ClickableSpan? {
185+
private fun <T> getSpanInCoords(x: Int, y: Int, clazz: Class<T>): T? {
194186
val offset = getTextOffsetAt(x, y)
195187
if (offset < 0) {
196188
return null
197189
}
198190

199191
val spanned = text as? Spanned ?: return null
200192

201-
val clickableSpans = spanned.getSpans(offset, offset, ClickableSpan::class.java)
202-
if (clickableSpans.isNotEmpty()) {
203-
return clickableSpans[0]
193+
val spans = spanned.getSpans(offset, offset, clazz)
194+
if (spans.isEmpty()) {
195+
return null
196+
}
197+
198+
// When we have multiple spans marked with SPAN_EXCLUSIVE_INCLUSIVE next to each other, both
199+
// spans are returned by getSpans
200+
check(spans.size <= 2)
201+
for (span in spans) {
202+
val spanFlags = spanned.getSpanFlags(span)
203+
val inclusiveStart =
204+
if ((spanFlags and Spanned.SPAN_INCLUSIVE_INCLUSIVE) != 0 ||
205+
(spanFlags and Spanned.SPAN_INCLUSIVE_EXCLUSIVE) != 0) {
206+
spanned.getSpanStart(span)
207+
} else {
208+
spanned.getSpanStart(span) + 1
209+
}
210+
val inclusiveEnd =
211+
if ((spanFlags and Spanned.SPAN_INCLUSIVE_INCLUSIVE) != 0 ||
212+
(spanFlags and Spanned.SPAN_EXCLUSIVE_INCLUSIVE) != 0) {
213+
spanned.getSpanEnd(span)
214+
} else {
215+
spanned.getSpanEnd(span) - 1
216+
}
217+
218+
if (offset >= inclusiveStart && offset <= inclusiveEnd) {
219+
return span
220+
}
204221
}
205222

206223
return null
207224
}
208225

209226
private fun getTextOffsetAt(x: Int, y: Int): Int {
227+
val layoutX = x - paddingLeft
228+
val layoutY = y - (paddingTop + (preparedLayout?.verticalOffset?.roundToInt() ?: 0))
229+
210230
val layout = preparedLayout?.layout ?: return -1
211-
val line = layout.getLineForVertical(y)
231+
val line = layout.getLineForVertical(layoutY)
212232

213233
val left: Float
214234
val right: Float
@@ -238,12 +258,12 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
238258
right = if (rtl) layout.getParagraphRight(line).toFloat() else layout.getLineMax(line)
239259
}
240260

241-
if (x < left || x > right) {
261+
if (layoutX < left || layoutX > right) {
242262
return -1
243263
}
244264

245265
return try {
246-
layout.getOffsetForHorizontal(line, x.toFloat())
266+
layout.getOffsetForHorizontal(line, layoutX.toFloat())
247267
} catch (e: ArrayIndexOutOfBoundsException) {
248268
// This happens for bidi text on Android 7-8.
249269
// See
@@ -332,20 +352,6 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
332352
}
333353
}
334354

335-
override fun reactTagForTouch(touchX: Float, touchY: Float): Int {
336-
val offset = getTextOffsetAt(touchX.roundToInt(), touchY.roundToInt())
337-
if (offset < 0) {
338-
return id
339-
}
340-
341-
val spanned = text as? Spanned ?: return id
342-
val reactSpans = spanned.getSpans(offset, offset, ReactTagSpan::class.java)
343-
check(reactSpans.size <= 1)
344-
345-
return if (reactSpans.isNotEmpty()) {
346-
reactSpans[0].reactTag
347-
} else {
348-
id
349-
}
350-
}
355+
override fun reactTagForTouch(touchX: Float, touchY: Float): Int =
356+
getSpanInCoords(x.roundToInt(), y.roundToInt(), ReactTagSpan::class.java)?.reactTag ?: id
351357
}

0 commit comments

Comments
 (0)