Move text-specific a11y logic in ReactAccessibilityDelegate to subclass (#49377)

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

ReactAccessibilityDelegate exists to handle much of the accessibility tasks in the various Views in RN. There is quite a bit of text specific logic, mostly related to virtual views and nested links within a TextView.

I decided to subclass this into a TextView-specific version because I need this delegate to reference TextView or ReactClickableSpan, which live under `react/views` while ReactAccessibilityDelegate live under `react/uimanager`. The former depends on the latter, so making the latter depend on the former would for a dependency cycle that would break builds. I thought about making a separate package for this but both `react/views` and `react/uimanager` need to include ReactAccessibilityDelegate so we would still have a cycle.

mAccessibilityLinks is only set on ReactTextViewManager, so this is purely a text thing. Subclassing is not the most ideal as it extends the inheritance chain some more but I do not see a better option.

Changelog: [Internal]

Reviewed By: mdvacca

Differential Revision: D69499115

fbshipit-source-id: 1720d20bb56ba1e1b5bd114d32bc70e80e3b4558
This commit is contained in:
Joe Vilches
2025-02-21 16:10:03 -08:00
committed by Facebook GitHub Bot
parent 50878c248d
commit d65d846ccf
5 changed files with 319 additions and 239 deletions
@@ -3822,6 +3822,7 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo
public fun setTranslateY (Landroid/view/View;F)V
public fun setViewState (Landroid/view/View;Lcom/facebook/react/bridge/ReadableMap;)V
public fun setZIndex (Landroid/view/View;F)V
protected fun updateViewAccessibility (Landroid/view/View;)V
}
public abstract class com/facebook/react/uimanager/BaseViewManagerDelegate : com/facebook/react/uimanager/ViewManagerDelegate {
@@ -4106,7 +4107,7 @@ public class com/facebook/react/uimanager/ReactAccessibilityDelegate : androidx/
public fun <init> (Landroid/view/View;ZI)V
public static fun createNodeInfoFromView (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;
public fun getAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat;
protected fun getFirstSpan (IILjava/lang/Class;)Ljava/lang/Object;
protected fun getHostView ()Landroid/view/View;
public static fun getTalkbackDescription (Landroid/view/View;Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;)Ljava/lang/CharSequence;
protected fun getVirtualViewAt (FF)I
protected fun getVisibleVirtualViews (Ljava/util/List;)V
@@ -4124,13 +4125,7 @@ public class com/facebook/react/uimanager/ReactAccessibilityDelegate : androidx/
public static fun resetDelegate (Landroid/view/View;ZI)V
public static fun setDelegate (Landroid/view/View;ZI)V
public static fun setRole (Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityRole;Landroid/content/Context;)V
}
public class com/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks {
public fun <init> ([Landroid/text/style/ClickableSpan;Landroid/text/Spannable;)V
public fun getLinkById (I)Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
public fun getLinkBySpanPos (II)Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
public fun size ()I
public fun superGetAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat;
}
public final class com/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityRole : java/lang/Enum {
@@ -6922,6 +6917,8 @@ public class com/facebook/react/views/text/ReactTextViewManager : com/facebook/r
public fun updateExtraData (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/Object;)V
public synthetic fun updateState (Landroid/view/View;Lcom/facebook/react/uimanager/ReactStylesDiffMap;Lcom/facebook/react/uimanager/StateWrapper;)Ljava/lang/Object;
public fun updateState (Lcom/facebook/react/views/text/ReactTextView;Lcom/facebook/react/uimanager/ReactStylesDiffMap;Lcom/facebook/react/uimanager/StateWrapper;)Ljava/lang/Object;
protected synthetic fun updateViewAccessibility (Landroid/view/View;)V
protected fun updateViewAccessibility (Lcom/facebook/react/views/text/ReactTextView;)V
}
public abstract interface class com/facebook/react/views/text/ReactTextViewManagerCallback {
@@ -626,7 +626,7 @@ public abstract class BaseViewManager<T extends View, C extends LayoutShadowNode
throw new IllegalStateException("Invalid float property value: " + value);
}
private void updateViewAccessibility(@NonNull T view) {
protected void updateViewAccessibility(@NonNull T view) {
ReactAccessibilityDelegate.setDelegate(
view, view.isFocusable(), view.getImportantForAccessibility());
}
@@ -8,22 +8,15 @@
package com.facebook.react.uimanager;
import android.content.Context;
import android.graphics.Paint;
import android.graphics.Rect;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.text.Layout;
import android.text.Spannable;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ClickableSpan;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
@@ -44,12 +37,10 @@ import com.facebook.react.bridge.ReadableMapKeySetIterator;
import com.facebook.react.bridge.ReadableType;
import com.facebook.react.bridge.UIManager;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole;
import com.facebook.react.uimanager.common.ViewUtil;
import com.facebook.react.uimanager.events.Event;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.util.ReactFindViewUtil;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -79,7 +70,6 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
}
private final View mView;
private final AccessibilityLinks mAccessibilityLinks;
private Handler mHandler;
@@ -405,7 +395,11 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
// announcement coalescing.
mView.setFocusable(originalFocus);
ViewCompat.setImportantForAccessibility(mView, originalImportantForAccessibility);
mAccessibilityLinks = (AccessibilityLinks) mView.getTag(R.id.accessibility_links);
}
// The View this delegate is attached to
protected View getHostView() {
return mView;
}
@Nullable View mAccessibilityLabelledBy;
@@ -716,143 +710,17 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
@Override
protected int getVirtualViewAt(float x, float y) {
if (mAccessibilityLinks == null
|| mAccessibilityLinks.size() == 0
|| !(mView instanceof TextView)) {
return INVALID_ID;
}
TextView textView = (TextView) mView;
if (!(textView.getText() instanceof Spanned)) {
return INVALID_ID;
}
Layout layout = textView.getLayout();
if (layout == null) {
return INVALID_ID;
}
x -= textView.getTotalPaddingLeft();
y -= textView.getTotalPaddingTop();
x += textView.getScrollX();
y += textView.getScrollY();
int line = layout.getLineForVertical((int) y);
int charOffset = layout.getOffsetForHorizontal(line, x);
ClickableSpan clickableSpan = getFirstSpan(charOffset, charOffset, ClickableSpan.class);
if (clickableSpan == null) {
return INVALID_ID;
}
Spanned spanned = (Spanned) textView.getText();
int start = spanned.getSpanStart(clickableSpan);
int end = spanned.getSpanEnd(clickableSpan);
final AccessibilityLinks.AccessibleLink link = mAccessibilityLinks.getLinkBySpanPos(start, end);
return link != null ? link.id : INVALID_ID;
return INVALID_ID;
}
@Override
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
if (mAccessibilityLinks == null) {
return;
}
for (int i = 0; i < mAccessibilityLinks.size(); i++) {
virtualViewIds.add(i);
}
}
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {}
@Override
protected void onPopulateNodeForVirtualView(
int virtualViewId, @NonNull AccessibilityNodeInfoCompat node) {
// If we get an invalid virtualViewId for some reason (which is known to happen in API 19 and
// below), return an "empty" node to prevent from crashing. This will never be presented to
// the user, as Talkback filters out nodes with no content to announce.
if (mAccessibilityLinks == null) {
node.setContentDescription("");
node.setBoundsInParent(new Rect(0, 0, 1, 1));
return;
}
final AccessibilityLinks.AccessibleLink accessibleTextSpan =
mAccessibilityLinks.getLinkById(virtualViewId);
if (accessibleTextSpan == null) {
node.setContentDescription("");
node.setBoundsInParent(new Rect(0, 0, 1, 1));
return;
}
// NOTE: The span may not actually have visible bounds within its parent,
// due to line limits, etc.
final Rect bounds = getBoundsInParent(accessibleTextSpan);
if (bounds == null) {
node.setContentDescription("");
node.setBoundsInParent(new Rect(0, 0, 1, 1));
return;
}
node.setContentDescription(accessibleTextSpan.description);
node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
node.setBoundsInParent(bounds);
node.setRoleDescription(mView.getResources().getString(R.string.link_description));
node.setClassName(AccessibilityRole.getValue(AccessibilityRole.BUTTON));
}
private Rect getBoundsInParent(AccessibilityLinks.AccessibleLink accessibleLink) {
// This view is not a text view, so return the entire views bounds.
if (!(mView instanceof TextView)) {
return new Rect(0, 0, mView.getWidth(), mView.getHeight());
}
TextView textView = (TextView) mView;
Layout textViewLayout = textView.getLayout();
if (textViewLayout == null) {
return new Rect(0, 0, textView.getWidth(), textView.getHeight());
}
double startOffset = accessibleLink.start;
double endOffset = accessibleLink.end;
// Ensure the link hasn't been ellipsized away; in such cases,
// getPrimaryHorizontal will crash (and the link isn't rendered anyway).
int startOffsetLineNumber = textViewLayout.getLineForOffset((int) startOffset);
int lineEndOffset = textViewLayout.getLineEnd(startOffsetLineNumber);
if (startOffset > lineEndOffset) {
return null;
}
Rect rootRect = new Rect();
double startXCoordinates = textViewLayout.getPrimaryHorizontal((int) startOffset);
final Paint paint = new Paint();
AbsoluteSizeSpan sizeSpan =
getFirstSpan(accessibleLink.start, accessibleLink.end, AbsoluteSizeSpan.class);
float textSize = sizeSpan != null ? sizeSpan.getSize() : textView.getTextSize();
paint.setTextSize(textSize);
int textWidth = (int) Math.ceil(paint.measureText(accessibleLink.description));
int endOffsetLineNumber = textViewLayout.getLineForOffset((int) endOffset);
boolean isMultiline = startOffsetLineNumber != endOffsetLineNumber;
textViewLayout.getLineBounds(startOffsetLineNumber, rootRect);
int verticalOffset = textView.getScrollY() + textView.getTotalPaddingTop();
rootRect.top += verticalOffset;
rootRect.bottom += verticalOffset;
rootRect.left += startXCoordinates + textView.getTotalPaddingLeft() - textView.getScrollX();
// 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
// that center point is outside the spannable, it will click on something else. There is no
// harm in not outlining the wrapped part of the string, as the text for the whole string will
// be read regardless of the bounding box.
if (isMultiline) {
return new Rect(rootRect.left, rootRect.top, rootRect.right, rootRect.bottom);
}
return new Rect(rootRect.left, rootRect.top, rootRect.left + textWidth, rootRect.bottom);
node.setContentDescription("");
node.setBoundsInParent(new Rect(0, 0, 1, 1));
}
@Override
@@ -861,97 +729,17 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
return false;
}
protected @Nullable <T> T getFirstSpan(int start, int end, Class<T> classType) {
if (!(mView instanceof TextView) || !(((TextView) mView).getText() instanceof Spanned)) {
return null;
}
Spanned spanned = (Spanned) ((TextView) mView).getText();
T[] spans = spanned.getSpans(start, end, classType);
return spans.length > 0 ? spans[0] : null;
}
public static class AccessibilityLinks {
private final List<AccessibleLink> mLinks;
public AccessibilityLinks(ClickableSpan[] spans, Spannable text) {
ArrayList<AccessibleLink> links = new ArrayList<>();
for (int i = 0; i < spans.length; i++) {
ClickableSpan span = spans[i];
int start = text.getSpanStart(span);
int end = text.getSpanEnd(span);
// zero length spans, and out of range spans should not be included.
if (start == end || start < 0 || end < 0 || start > text.length() || end > text.length()) {
continue;
}
final AccessibleLink link = new AccessibleLink();
link.description = text.subSequence(start, end).toString();
link.start = start;
link.end = end;
// ID is the reverse of what is expected, since the ClickableSpans are returned in reverse
// order due to being added in reverse order. If we don't do this, focus will move to the
// last link first and move backwards.
//
// If this approach becomes unreliable, we should instead look at their start position and
// order them manually.
link.id = spans.length - 1 - i;
links.add(link);
}
mLinks = links;
}
@Nullable
public AccessibleLink getLinkById(int id) {
for (AccessibleLink link : mLinks) {
if (link.id == id) {
return link;
}
}
return null;
}
@Nullable
public AccessibleLink getLinkBySpanPos(int start, int end) {
for (AccessibleLink link : mLinks) {
if (link.start == start && link.end == end) {
return link;
}
}
return null;
}
public int size() {
return mLinks.size();
}
private static class AccessibleLink {
public String description;
public int start;
public int end;
public int id;
}
}
@Override
public @Nullable AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) {
// Only set a NodeProvider if we have virtual views, otherwise just return null here so that
// we fall back to the View class's default behavior. If we don't do this, then Views with
// no virtual children will fall back to using ExploreByTouchHelper's onPopulateNodeForHost
// method to populate their AccessibilityNodeInfo, which defaults to doing nothing, so no
// AccessibilityNodeInfo will be created. Alternatively, we could override
// onPopulateNodeForHost instead, and have it create an AccessibilityNodeInfo for the host
// but this is what the default View class does by itself, so we may as well defer to it.
if (mAccessibilityLinks != null) {
return super.getAccessibilityNodeProvider(host);
}
return null;
}
// This exists so classes that extend this can properly call super's impl of this method while
// still being able to override it properly for this class
public @Nullable AccessibilityNodeProviderCompat superGetAccessibilityNodeProvider(View host) {
return super.getAccessibilityNodeProvider(host);
}
/**
* Determines if the supplied {@link View} and {@link AccessibilityNodeInfoCompat} has any
* children which are not independently accessibility focusable and also have a spoken
@@ -0,0 +1,290 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.react.views.text
import android.graphics.Paint
import android.graphics.Rect
import android.text.Spannable
import android.text.Spanned
import android.text.style.AbsoluteSizeSpan
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
import com.facebook.react.R
import com.facebook.react.uimanager.ReactAccessibilityDelegate
import kotlin.math.ceil
internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
public constructor(
view: View,
originalFocus: Boolean,
originalImportantForAccessibility: Int
) : super(view, originalFocus, originalImportantForAccessibility) {
accessibilityLinks = hostView.getTag(R.id.accessibility_links) as AccessibilityLinks?
}
private var accessibilityLinks: AccessibilityLinks? = null
public companion object {
public fun setDelegate(
view: View,
originalFocus: Boolean,
originalImportantForAccessibility: Int
) {
// if a view already has an accessibility delegate, replacing it could cause
// problems,so leave it alone.
if (!ViewCompat.hasAccessibilityDelegate(view) &&
(view.getTag(R.id.accessibility_role) != null ||
view.getTag(R.id.accessibility_state) != null ||
view.getTag(R.id.accessibility_actions) != null ||
view.getTag(R.id.react_test_id) != null ||
view.getTag(R.id.accessibility_collection_item) != null ||
view.getTag(R.id.accessibility_links) != null ||
view.getTag(R.id.role) != null)) {
ViewCompat.setAccessibilityDelegate(
view,
ReactTextViewAccessibilityDelegate(
view, originalFocus, originalImportantForAccessibility))
}
}
public fun resetDelegate(
view: View,
originalFocus: Boolean,
originalImportantForAccessibility: Int
) {
ViewCompat.setAccessibilityDelegate(
view,
ReactTextViewAccessibilityDelegate(
view, originalFocus, originalImportantForAccessibility))
}
}
override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int?>) {
val accessibilityLinks = accessibilityLinks ?: return
for (i in 0 until accessibilityLinks.size()) {
virtualViewIds.add(i)
}
}
override fun getVirtualViewAt(x: Float, y: Float): Int {
val accessibilityLinks = accessibilityLinks ?: return INVALID_ID
if (accessibilityLinks.size() == 0 || hostView !is TextView) {
return INVALID_ID
}
var x = x
var y = y
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 clickableSpan =
getFirstSpan(charOffset, charOffset, ClickableSpan::class.java) ?: return INVALID_ID
val spanned = textView.text as Spanned
val start = spanned.getSpanStart(clickableSpan)
val end = spanned.getSpanEnd(clickableSpan)
val link: AccessibilityLinks.AccessibleLink? = accessibilityLinks.getLinkBySpanPos(start, end)
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
}
val spanned = (hostView as TextView).text as Spanned
val spans = spanned.getSpans(start, end, classType)
return if (spans.isNotEmpty()) spans[0] 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
// below), return an "empty" node to prevent from crashing. This will never be presented to
// the user, as Talkback filters out nodes with no content to announce.
val accessibilityLinks = accessibilityLinks
if (accessibilityLinks == null) {
node.contentDescription = ""
node.setBoundsInParent(Rect(0, 0, 1, 1))
return
}
val accessibleTextSpan: AccessibilityLinks.AccessibleLink? =
accessibilityLinks.getLinkById(virtualViewId)
if (accessibleTextSpan == null) {
node.contentDescription = ""
node.setBoundsInParent(Rect(0, 0, 1, 1))
return
}
// NOTE: The span may not actually have visible bounds within its parent,
// due to line limits, etc.
val bounds = getBoundsInParent(accessibleTextSpan)
if (bounds == null) {
node.contentDescription = ""
node.setBoundsInParent(Rect(0, 0, 1, 1))
return
}
node.contentDescription = accessibleTextSpan.description
node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK)
node.setBoundsInParent(bounds)
node.roleDescription = hostView.resources.getString(R.string.link_description)
node.className = AccessibilityRole.getValue(AccessibilityRole.BUTTON)
}
private fun getBoundsInParent(accessibleLink: AccessibilityLinks.AccessibleLink): Rect? {
// This view is not a text view, so return the entire views bounds.
if (hostView !is TextView) {
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 startOffset = accessibleLink.start
val endOffset = accessibleLink.end
// Ensure the link hasn't been ellipsized away; in such cases,
// getPrimaryHorizontal will crash (and the link isn't rendered anyway).
val startOffsetLineNumber = textViewLayout.getLineForOffset(startOffset)
val lineEndOffset = textViewLayout.getLineEnd(startOffsetLineNumber)
if (startOffset > lineEndOffset) {
return null
}
val rootRect = Rect()
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
rootRect.top += verticalOffset
rootRect.bottom += verticalOffset
rootRect.left =
(rootRect.left + (startXCoordinates + textView.totalPaddingLeft - textView.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
// that center point is outside the spannable, it will click on something else. There is no
// harm in not outlining the wrapped part of the string, as the text for the whole string will
// be read regardless of the bounding box.
if (isMultiline) {
return Rect(rootRect.left, rootRect.top, rootRect.right, rootRect.bottom)
}
return Rect(rootRect.left, rootRect.top, rootRect.left + textWidth, rootRect.bottom)
}
override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProviderCompat? {
// Only set a NodeProvider if we have virtual views, otherwise just return null here so that
// we fall back to the View class's default behavior. If we don't do this, then Views with
// no virtual children will fall back to using ExploreByTouchHelper's onPopulateNodeForHost
// method to populate their AccessibilityNodeInfo, which defaults to doing nothing, so no
// AccessibilityNodeInfo will be created. Alternatively, we could override
// onPopulateNodeForHost instead, and have it create an AccessibilityNodeInfo for the host
// but this is what the default View class does by itself, so we may as well defer to it.
if (accessibilityLinks != null) {
return superGetAccessibilityNodeProvider(host)
}
return null
}
public class AccessibilityLinks(spans: Array<ClickableSpan?>, text: Spannable) {
private val links: List<AccessibleLink>
init {
val accessibleLinks = mutableListOf<AccessibleLink>()
for (i in spans.indices) {
val span = spans[i]
val start = text.getSpanStart(span)
val end = text.getSpanEnd(span)
// zero length spans, and out of range spans should not be included.
if (start == end || start < 0 || end < 0 || start > text.length || end > text.length) {
continue
}
val link = AccessibleLink()
link.description = text.subSequence(start, end).toString()
link.start = start
link.end = end
// ID is the reverse of what is expected, since the ClickableSpans are returned in reverse
// order due to being added in reverse order. If we don't do this, focus will move to the
// last link first and move backwards.
//
// If this approach becomes unreliable, we should instead look at their start position and
// order them manually.
link.id = spans.size - 1 - i
accessibleLinks.add(link)
}
links = accessibleLinks
}
public fun getLinkById(id: Int): AccessibleLink? {
for (link in links) {
if (link.id == id) {
return link
}
}
return null
}
public fun getLinkBySpanPos(start: Int, end: Int): AccessibleLink? {
for (link in links) {
if (link.start == start && link.end == end) {
return link
}
}
return null
}
public fun size(): Int {
return links.size
}
public class AccessibleLink {
public var description: String? = null
public var start: Int = 0
public var end: Int = 0
public var id: Int = 0
}
}
}
@@ -21,7 +21,6 @@ import com.facebook.react.internal.SystraceSection;
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags;
import com.facebook.react.module.annotations.ReactModule;
import com.facebook.react.uimanager.IViewManagerWithChildren;
import com.facebook.react.uimanager.ReactAccessibilityDelegate;
import com.facebook.react.uimanager.ReactStylesDiffMap;
import com.facebook.react.uimanager.StateWrapper;
import com.facebook.react.uimanager.ThemedReactContext;
@@ -83,6 +82,12 @@ public class ReactTextViewManager
return REACT_CLASS;
}
@Override
protected void updateViewAccessibility(@NonNull ReactTextView view) {
ReactTextViewAccessibilityDelegate.Companion.setDelegate(
view, view.isFocusable(), view.getImportantForAccessibility());
}
@Override
public ReactTextView createViewInstance(ThemedReactContext context) {
return new ReactTextView(context);
@@ -106,8 +111,8 @@ public class ReactTextViewManager
if (clickableSpans.length > 0) {
view.setTag(
R.id.accessibility_links,
new ReactAccessibilityDelegate.AccessibilityLinks(clickableSpans, spannable));
ReactAccessibilityDelegate.resetDelegate(
new ReactTextViewAccessibilityDelegate.AccessibilityLinks(clickableSpans, spannable));
ReactTextViewAccessibilityDelegate.Companion.resetDelegate(
view, view.isFocusable(), view.getImportantForAccessibility());
}
}