Skip to content

Commit 20d8805

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 20d8805

File tree

1 file changed

+40
-23
lines changed

1 file changed

+40
-23
lines changed

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

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -190,25 +190,56 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
190190
* @return a clickable span that's located where the click occurred, or: `null` if no clickable
191191
* span was located there
192192
*/
193-
private fun getClickableSpanInCoords(x: Int, y: Int): ClickableSpan? {
193+
private fun getClickableSpanInCoords(x: Int, y: Int): ClickableSpan? =
194+
getSpanInCoords(x, y, ClickableSpan::class.java)
195+
196+
private fun <T> getSpanInCoords(x: Int, y: Int, clazz: Class<T>): T? {
194197
val offset = getTextOffsetAt(x, y)
195198
if (offset < 0) {
196199
return null
197200
}
198201

199202
val spanned = text as? Spanned ?: return null
200203

201-
val clickableSpans = spanned.getSpans(offset, offset, ClickableSpan::class.java)
202-
if (clickableSpans.isNotEmpty()) {
203-
return clickableSpans[0]
204+
val spans = spanned.getSpans(offset, offset, clazz)
205+
if (spans.isEmpty()) {
206+
return null
207+
}
208+
209+
// When we have multiple spans marked with SPAN_EXCLUSIVE_INCLUSIVE next to each other, both
210+
// spans are returned by getSpans
211+
check(spans.size <= 2)
212+
for (span in spans) {
213+
val spanFlags = spanned.getSpanFlags(span)
214+
val inclusiveStart =
215+
if ((spanFlags and Spanned.SPAN_INCLUSIVE_INCLUSIVE) != 0 ||
216+
(spanFlags and Spanned.SPAN_INCLUSIVE_EXCLUSIVE) != 0) {
217+
spanned.getSpanStart(span)
218+
} else {
219+
spanned.getSpanStart(span) + 1
220+
}
221+
val inclusiveEnd =
222+
if ((spanFlags and Spanned.SPAN_INCLUSIVE_INCLUSIVE) != 0 ||
223+
(spanFlags and Spanned.SPAN_EXCLUSIVE_INCLUSIVE) != 0) {
224+
spanned.getSpanEnd(span)
225+
} else {
226+
spanned.getSpanEnd(span) - 1
227+
}
228+
229+
if (offset >= inclusiveStart && offset <= inclusiveEnd) {
230+
return span
231+
}
204232
}
205233

206234
return null
207235
}
208236

209237
private fun getTextOffsetAt(x: Int, y: Int): Int {
238+
val layoutX = x - paddingLeft
239+
val layoutY = y - (paddingTop + (preparedLayout?.verticalOffset?.roundToInt() ?: 0))
240+
210241
val layout = preparedLayout?.layout ?: return -1
211-
val line = layout.getLineForVertical(y)
242+
val line = layout.getLineForVertical(layoutY)
212243

213244
val left: Float
214245
val right: Float
@@ -238,12 +269,12 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
238269
right = if (rtl) layout.getParagraphRight(line).toFloat() else layout.getLineMax(line)
239270
}
240271

241-
if (x < left || x > right) {
272+
if (layoutX < left || layoutX > right) {
242273
return -1
243274
}
244275

245276
return try {
246-
layout.getOffsetForHorizontal(line, x.toFloat())
277+
layout.getOffsetForHorizontal(line, layoutX.toFloat())
247278
} catch (e: ArrayIndexOutOfBoundsException) {
248279
// This happens for bidi text on Android 7-8.
249280
// See
@@ -332,20 +363,6 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
332363
}
333364
}
334365

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-
}
366+
override fun reactTagForTouch(touchX: Float, touchY: Float): Int =
367+
getSpanInCoords(x.roundToInt(), y.roundToInt(), ReactTagSpan::class.java)?.reactTag ?: id
351368
}

0 commit comments

Comments
 (0)