mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
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:
committed by
Facebook GitHub Bot
parent
99505e0c74
commit
14e53e4845
+26
-113
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+53
-52
@@ -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? {
|
||||
|
||||
Reference in New Issue
Block a user