Allow links in facsimile to be keyboard focusable (#51305)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/51305

tsia, there is a lot of TextView specific API calls and instance checks in the delegate that need to be modified. Additionally, facsimile has some custom focusing logic we do not need if we have a delegate

I opted to just do a lot of instance specific logic using `is` . That seems easier for the time being with this text view that should replace our other text view over time.

I also expose a new way of focusing a span on facsimile, which may not be the best way to do that, lmk

Changelog: [Internal]

Reviewed By: NickGerleman

Differential Revision: D74104419

fbshipit-source-id: 87c2259bb1698d93afad88ed91cb6322b90714f0
This commit is contained in:
Joe Vilches
2025-05-14 14:24:21 -07:00
committed by Facebook GitHub Bot
parent 99505e0c74
commit 14e53e4845
2 changed files with 79 additions and 165 deletions
@@ -22,6 +22,7 @@ import android.view.ViewGroup
import androidx.annotation.ColorInt
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.core.view.ViewCompat
import com.facebook.react.uimanager.BackgroundStyleApplicator
import com.facebook.react.uimanager.style.Overflow
import kotlin.collections.ArrayList
@@ -122,10 +123,12 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
// No-op
}
private fun setSelection(span: ClickableSpan) {
public fun setSelection(start: Int, end: Int) {
val textLayout = checkNotNull(layout)
val start = (textLayout.text as Spanned).getSpanStart(span)
val end = (textLayout.text as Spanned).getSpanEnd(span)
if (start < 0 || end > textLayout.text.length || start >= end) {
throw IllegalArgumentException(
"setSelection start and end are not in valid range. start: $start, end: $end, text length: ${textLayout.text.length}")
}
val textSelection = selection
if (textSelection == null) {
@@ -141,7 +144,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
invalidate()
}
private fun clearSelection() {
public fun clearSelection() {
selection = null
invalidate()
}
@@ -161,18 +164,21 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
val x = event.x.toInt()
val y = event.y.toInt()
val clickedSpan = getClickableSpanInCoords(x, y)
val clickableSpan = getClickableSpanInCoords(x, y)
if (clickedSpan == null) {
if (clickableSpan == null) {
clearSelection()
return super.onTouchEvent(event)
}
if (action == MotionEvent.ACTION_UP) {
clearSelection()
clickedSpan.onClick(this)
clickableSpan.onClick(this)
} else if (action == MotionEvent.ACTION_DOWN) {
setSelection(clickedSpan)
val textLayout = checkNotNull(layout)
val start = (textLayout.text as Spanned).getSpanStart(clickableSpan)
val end = (textLayout.text as Spanned).getSpanEnd(clickableSpan)
setSelection(start, end)
}
return true
@@ -249,7 +255,6 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
}
public override fun dispatchHoverEvent(event: MotionEvent): Boolean =
// TODO T221698305: Dispatch to AccessibilityDelegate
super.dispatchHoverEvent(event)
public override fun onFocusChanged(
@@ -261,99 +266,20 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
clearSelection()
}
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
// TODO T221698305: Dispatch to AccessibilityDelegate
val accessibilityDelegateCompat = ViewCompat.getAccessibilityDelegate(this)
if (accessibilityDelegateCompat != null &&
accessibilityDelegateCompat is ReactTextViewAccessibilityDelegate) {
accessibilityDelegateCompat.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
// TODO T221698305: Dispatch to AccessibilityDelegate
super.dispatchKeyEvent(event)
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val accessibilityDelegateCompat = ViewCompat.getAccessibilityDelegate(this)
val delegateHandled =
accessibilityDelegateCompat is ReactTextViewAccessibilityDelegate &&
accessibilityDelegateCompat.dispatchKeyEvent(event)
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (isEnabled &&
clickableSpans.isNotEmpty() &&
selection == null &&
(isDirectionKey(keyCode) || keyCode == KeyEvent.KEYCODE_TAB)) {
// View just received focus due to keyboard navigation. Nothing is currently selected,
// let's select first span according to the navigation direction.
var targetSpan: ClickableSpan? = null
if (isDirectionKey(keyCode) && event.hasNoModifiers()) {
if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
targetSpan = clickableSpans[0]
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_UP) {
targetSpan = clickableSpans[clickableSpans.size - 1]
}
}
if (keyCode == KeyEvent.KEYCODE_TAB) {
if (event.hasNoModifiers()) {
targetSpan = clickableSpans[0]
} else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
targetSpan = clickableSpans[clickableSpans.size - 1]
}
}
if (targetSpan != null) {
setSelection(targetSpan)
return true
}
}
return super.onKeyUp(keyCode, event)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (isEnabled &&
clickableSpans.isNotEmpty() &&
(isDirectionKey(keyCode) || isConfirmKey(keyCode)) &&
event.hasNoModifiers()) {
val selectedSpanIndex = selectedSpanIndex()
if (selectedSpanIndex == -1) {
return super.onKeyDown(keyCode, event)
}
if (isDirectionKey(keyCode)) {
val direction =
if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
1
} else {
// keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_UP
-1
}
val repeatCount = 1 + event.repeatCount
val targetIndex = selectedSpanIndex + direction * repeatCount
if (targetIndex >= 0 && targetIndex < clickableSpans.size) {
setSelection(clickableSpans[targetIndex])
return true
}
}
if (isConfirmKey(keyCode) && event.repeatCount == 0) {
clearSelection()
clickableSpans[selectedSpanIndex].onClick(this)
return true
}
}
return super.onKeyDown(keyCode, event)
}
private fun selectedSpanIndex(): Int {
val spanned = text as? Spanned ?: return -1
val textSelection = selection ?: return -1
if (clickableSpans.isEmpty()) {
return -1
}
for (i in clickableSpans.indices) {
val span = clickableSpans[i]
val spanStart = spanned.getSpanStart(span)
val spanEnd = spanned.getSpanEnd(span)
if (spanStart == textSelection.start && spanEnd == textSelection.end) {
return i
}
}
return -1
return delegateHandled || super.dispatchKeyEvent(event)
}
@RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -386,21 +312,8 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
private companion object {
private val selectionPaint = Paint()
private fun isDirectionKey(keyCode: Int): Boolean =
keyCode == KeyEvent.KEYCODE_DPAD_LEFT ||
keyCode == KeyEvent.KEYCODE_DPAD_RIGHT ||
keyCode == KeyEvent.KEYCODE_DPAD_UP ||
keyCode == KeyEvent.KEYCODE_DPAD_DOWN
private fun isConfirmKey(keyCode: Int): Boolean =
keyCode == KeyEvent.KEYCODE_DPAD_CENTER ||
keyCode == KeyEvent.KEYCODE_ENTER ||
keyCode == KeyEvent.KEYCODE_SPACE ||
keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER
private fun filterClickableSpans(text: CharSequence): List<ClickableSpan> {
if (text !is Spanned ||
text.nextSpanTransition(0, text.length, ClickableSpan::class.java) == text.length) {
if (text !is Spanned) {
return emptyList()
}
@@ -7,11 +7,10 @@
package com.facebook.react.views.text
import android.graphics.Paint
import android.graphics.Rect
import android.os.Bundle
import android.text.Layout
import android.text.Spanned
import android.text.style.AbsoluteSizeSpan
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
@@ -21,7 +20,6 @@ import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
import com.facebook.react.R
import com.facebook.react.uimanager.ReactAccessibilityDelegate
import com.facebook.react.views.text.internal.span.ReactClickableSpan
import kotlin.math.ceil
internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
public constructor(
@@ -76,16 +74,19 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
val link = accessibilityLinks?.getLinkById(virtualViewId) ?: return
val span = getFirstSpan(link.start, link.end, ClickableSpan::class.java)
if (span == null || span !is ReactClickableSpan || hostView !is ReactTextView) {
return
}
val span = getFirstSpan(link.start, link.end, ClickableSpan::class.java) ?: return
// TODO: When we refactor ReactTextView, implement this using
// https://developer.android.com/reference/android/text/Layout
span.isKeyboardFocused = hasFocus
span.focusBgColor = (hostView as TextView).highlightColor
hostView.invalidate()
if (span is ReactClickableSpan && hostView is TextView) {
span.isKeyboardFocused = hasFocus
span.focusBgColor = (hostView as TextView).highlightColor
hostView.invalidate()
} else if (hostView is PreparedLayoutTextView) {
if (hasFocus) {
(hostView as PreparedLayoutTextView).setSelection(link.start, link.end)
} else {
(hostView as PreparedLayoutTextView).clearSelection()
}
}
}
override fun onPerformActionForVirtualView(
@@ -99,10 +100,7 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
val link = accessibilityLinks?.getLinkById(virtualViewId) ?: return false
val span = getFirstSpan(link.start, link.end, ClickableSpan::class.java)
if (span == null || span !is ReactClickableSpan) {
return false
}
val span = getFirstSpan(link.start, link.end, ClickableSpan::class.java) ?: return false
if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) {
span.onClick(hostView)
@@ -122,32 +120,26 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
override fun getVirtualViewAt(x: Float, y: Float): Int {
val accessibilityLinks = accessibilityLinks ?: return INVALID_ID
if (accessibilityLinks.size() == 0 || hostView !is TextView) {
if (accessibilityLinks.size() == 0 ||
(hostView !is TextView && hostView !is PreparedLayoutTextView)) {
return INVALID_ID
}
var x = x
var y = y
var localX = x
var localY = y
localX -= hostView.paddingLeft.toFloat()
localY -= hostView.paddingTop.toFloat()
localX += hostView.scrollX.toFloat()
localY += hostView.scrollY.toFloat()
val textView = hostView as TextView
if (textView.text !is Spanned) {
return INVALID_ID
}
val layout = textView.layout ?: return INVALID_ID
x -= textView.totalPaddingLeft.toFloat()
y -= textView.totalPaddingTop.toFloat()
x += textView.scrollX.toFloat()
y += textView.scrollY.toFloat()
val line = layout.getLineForVertical(y.toInt())
val charOffset = layout.getOffsetForHorizontal(line, x)
val layout = getLayoutFromHost() ?: return INVALID_ID
val line = layout.getLineForVertical(localY.toInt())
val charOffset = layout.getOffsetForHorizontal(line, localX)
val clickableSpan =
getFirstSpan(charOffset, charOffset, ClickableSpan::class.java) ?: return INVALID_ID
val spanned = textView.text as Spanned
val spanned = getSpannedFromHost() ?: return INVALID_ID
val start = spanned.getSpanStart(clickableSpan)
val end = spanned.getSpanEnd(clickableSpan)
@@ -155,16 +147,33 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
return link?.id ?: INVALID_ID
}
protected fun <T> getFirstSpan(start: Int, end: Int, classType: Class<T>?): T? {
if (hostView !is TextView || (hostView as TextView).text !is Spanned) {
return null
private fun getLayoutFromHost(): Layout? {
return if (hostView is PreparedLayoutTextView) {
(hostView as PreparedLayoutTextView).layout
} else if (hostView is TextView) {
(hostView as TextView).layout
} else {
null
}
}
val spanned = (hostView as TextView).text as Spanned
protected fun <T> getFirstSpan(start: Int, end: Int, classType: Class<T>?): T? {
val spanned = getSpannedFromHost() ?: return null
val spans = spanned.getSpans(start, end, classType)
return if (spans.isNotEmpty()) spans[0] else null
}
private fun getSpannedFromHost(): Spanned? {
val host = hostView
return if (host is PreparedLayoutTextView) {
host.layout?.text as? Spanned
} else if (host is TextView) {
host.text as? Spanned
} else {
null
}
}
@Suppress("DEPRECATION")
override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfoCompat) {
// If we get an invalid virtualViewId for some reason (which is known to happen in API 19 and
@@ -203,12 +212,11 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
private fun getBoundsInParent(accessibleLink: AccessibilityLinks.AccessibleLink): Rect? {
// This view is not a text view, so return the entire views bounds.
if (hostView !is TextView) {
if (hostView !is TextView && hostView !is PreparedLayoutTextView) {
return Rect(0, 0, hostView.width, hostView.height)
}
val textView = hostView as TextView
val textViewLayout = textView.layout ?: return Rect(0, 0, textView.width, textView.height)
val textViewLayout = getLayoutFromHost() ?: return Rect(0, 0, hostView.width, hostView.height)
val startOffset = accessibleLink.start
val endOffset = accessibleLink.end
@@ -225,22 +233,15 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
val startXCoordinates = textViewLayout.getPrimaryHorizontal(startOffset).toDouble()
val paint = Paint()
val sizeSpan =
getFirstSpan(accessibleLink.start, accessibleLink.end, AbsoluteSizeSpan::class.java)
val textSize = sizeSpan?.size?.toFloat() ?: textView.textSize
paint.textSize = textSize
val textWidth = ceil(paint.measureText(accessibleLink.description).toDouble()).toInt()
val endOffsetLineNumber = textViewLayout.getLineForOffset(endOffset)
val isMultiline = startOffsetLineNumber != endOffsetLineNumber
textViewLayout.getLineBounds(startOffsetLineNumber, rootRect)
val verticalOffset = textView.scrollY + textView.totalPaddingTop
val verticalOffset = hostView.scrollY + hostView.paddingTop
rootRect.top += verticalOffset
rootRect.bottom += verticalOffset
rootRect.left =
(rootRect.left + (startXCoordinates + textView.totalPaddingLeft - textView.scrollX)).toInt()
(rootRect.left + (startXCoordinates + hostView.paddingLeft - hostView.scrollX)).toInt()
// The bounds for multi-line strings should *only* include the first line. This is because for
// API 25 and below, Talkback's click is triggered at the center point of these bounds, and if
@@ -250,8 +251,8 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
if (isMultiline) {
return Rect(rootRect.left, rootRect.top, rootRect.right, rootRect.bottom)
}
return Rect(rootRect.left, rootRect.top, rootRect.left + textWidth, rootRect.bottom)
val endXCoordinates = textViewLayout.getPrimaryHorizontal(endOffset).toDouble()
return Rect(rootRect.left, rootRect.top, endXCoordinates.toInt(), rootRect.bottom)
}
override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProviderCompat? {