From 14e53e48456a27acf23fe394a3519ffdbbc30936 Mon Sep 17 00:00:00 2001 From: Joe Vilches Date: Wed, 14 May 2025 14:24:21 -0700 Subject: [PATCH] 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 --- .../views/text/PreparedLayoutTextView.kt | 139 ++++-------------- .../ReactTextViewAccessibilityDelegate.kt | 105 ++++++------- 2 files changed, 79 insertions(+), 165 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 7b1d5234ad1..95e7b33d314 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -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 { - if (text !is Spanned || - text.nextSpanTransition(0, text.length, ClickableSpan::class.java) == text.length) { + if (text !is Spanned) { return emptyList() } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt index 1d3b185d682..0a3ed28b419 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt @@ -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 getFirstSpan(start: Int, end: Int, classType: Class?): 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 getFirstSpan(start: Int, end: Int, classType: Class?): 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? {