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? {