Allow text links to be navigatable via keyboard by default v2 (#49381)

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

A much improved version of my previous attempt in D68306316 (https://github.com/facebook/react-native/pull/48773).

Instead of LinkMovementMethod which makes TextViews scrollable if they overflow, this implementation uses `ExploreByTouchHelper`'s `onVirtualViewKeyboardFocusChanged` and `onPerformActionForVirtualView` to handle focus changes and clicks on virtual views (aka spans in our case). This impl will correctly ellipsize text and allow tab to nav through the links.

Changelog: [Internal]

Reviewed By: NickGerleman

Differential Revision: D69551206

fbshipit-source-id: 6a88ccd507dc5534c1f494e3303c6bfc2bae5e9f
This commit is contained in:
Joe Vilches
2025-02-21 16:10:03 -08:00
committed by Facebook GitHub Bot
parent c9e6567881
commit a003be0f23
4 changed files with 81 additions and 1 deletions
@@ -6856,6 +6856,7 @@ public final class com/facebook/react/views/text/ReactTextUpdate$Companion {
public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/widget/AppCompatTextView, com/facebook/react/uimanager/ReactCompoundView {
public fun <init> (Landroid/content/Context;)V
protected fun dispatchHoverEvent (Landroid/view/MotionEvent;)Z
public fun dispatchKeyEvent (Landroid/view/KeyEvent;)Z
public fun getSpanned ()Landroid/text/Spannable;
public fun hasOverlappingRendering ()Z
public fun invalidateDrawable (Landroid/graphics/drawable/Drawable;)V
@@ -6863,6 +6864,7 @@ public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/wi
public fun onDetachedFromWindow ()V
protected fun onDraw (Landroid/graphics/Canvas;)V
public fun onFinishTemporaryDetach ()V
public final fun onFocusChanged (ZILandroid/graphics/Rect;)V
protected fun onLayout (ZIIII)V
protected fun onMeasure (II)V
public fun onStartTemporaryDetach ()V
@@ -9,6 +9,7 @@ package com.facebook.react.views.text;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.Layout;
@@ -19,6 +20,7 @@ import android.text.method.LinkMovementMethod;
import android.text.util.Linkify;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
@@ -756,6 +758,30 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie
return super.dispatchHoverEvent(event);
}
@Override
public final void onFocusChanged(
boolean gainFocus, int direction, @Nullable Rect previouslyFocusedRect) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
AccessibilityDelegateCompat accessibilityDelegateCompat =
ViewCompat.getAccessibilityDelegate(this);
if (accessibilityDelegateCompat != null
&& accessibilityDelegateCompat instanceof ReactTextViewAccessibilityDelegate) {
((ReactTextViewAccessibilityDelegate) accessibilityDelegateCompat)
.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
}
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
AccessibilityDelegateCompat accessibilityDelegateCompat =
ViewCompat.getAccessibilityDelegate(this);
return (accessibilityDelegateCompat != null
&& accessibilityDelegateCompat instanceof ReactTextViewAccessibilityDelegate
&& ((ReactTextViewAccessibilityDelegate) accessibilityDelegateCompat)
.dispatchKeyEvent(event))
|| super.dispatchKeyEvent(event);
}
private void applyTextAttributes() {
// Workaround for an issue where text can be cut off with an ellipsis when
// using certain font sizes and padding. Sets the provided text size and
@@ -9,6 +9,7 @@ package com.facebook.react.views.text
import android.graphics.Paint
import android.graphics.Rect
import android.os.Bundle
import android.text.Spannable
import android.text.Spanned
import android.text.style.AbsoluteSizeSpan
@@ -20,6 +21,7 @@ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
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 {
@@ -68,6 +70,49 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
}
}
override fun onVirtualViewKeyboardFocusChanged(virtualViewId: Int, hasFocus: Boolean) {
if (accessibilityLinks == null) {
return
}
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
}
// 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()
}
override fun onPerformActionForVirtualView(
virtualViewId: Int,
action: Int,
arguments: Bundle?
): Boolean {
if (accessibilityLinks == null) {
return false
}
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
}
if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) {
span.onClick(hostView)
return true
}
return false
}
override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int?>) {
val accessibilityLinks = accessibilityLinks ?: return
@@ -7,6 +7,7 @@
package com.facebook.react.views.text.internal.span
import android.graphics.Color
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.view.View
@@ -35,6 +36,9 @@ import com.facebook.react.views.view.ViewGroupClickEvent
* menu).
*/
public class ReactClickableSpan(public val reactTag: Int) : ClickableSpan(), ReactSpan {
public var isKeyboardFocused: Boolean = false
public var focusBgColor: Int = Color.TRANSPARENT
public override fun onClick(view: View) {
val context = view.context as ReactContext
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, reactTag)
@@ -43,7 +47,10 @@ public class ReactClickableSpan(public val reactTag: Int) : ClickableSpan(), Rea
}
public override fun updateDrawState(ds: TextPaint) {
// no-op to make sure we don't change the link color or add an underline by default, as the
// no super call so we don't change the link color or add an underline by default, as the
// superclass does.
if (isKeyboardFocused) {
ds.bgColor = focusBgColor
}
}
}