Skip to content

Commit

Permalink
Move text-specific a11y logic in ReactAccessibilityDelegate to subclass
Browse files Browse the repository at this point in the history
Summary:
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.

Differential Revision: D69499115
  • Loading branch information
joevilches authored and facebook-github-bot committed Feb 12, 2025
1 parent 1b066ba commit f60f139
Show file tree
Hide file tree
Showing 5 changed files with 349 additions and 239 deletions.
43 changes: 35 additions & 8 deletions packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -3929,6 +3929,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
public fun updateViewAccessibility (Landroid/view/View;)V
}

public abstract class com/facebook/react/uimanager/BaseViewManagerDelegate : com/facebook/react/uimanager/ViewManagerDelegate {
Expand Down Expand Up @@ -4222,7 +4223,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;
public 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
Expand All @@ -4240,13 +4241,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 {
Expand Down Expand Up @@ -7087,6 +7082,36 @@ public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/wi
protected fun verifyDrawable (Landroid/graphics/drawable/Drawable;)Z
}

public final class com/facebook/react/views/text/ReactTextViewAccessibilityDelegate : com/facebook/react/uimanager/ReactAccessibilityDelegate {
public static final field Companion Lcom/facebook/react/views/text/ReactTextViewAccessibilityDelegate$Companion;
public fun <init> (Landroid/view/View;ZI)V
public fun getAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat;
}

public final class com/facebook/react/views/text/ReactTextViewAccessibilityDelegate$AccessibilityLinks {
public fun <init> ([Landroid/text/style/ClickableSpan;Landroid/text/Spannable;)V
public final fun getLinkById (I)Lcom/facebook/react/views/text/ReactTextViewAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
public final fun getLinkBySpanPos (II)Lcom/facebook/react/views/text/ReactTextViewAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
public final fun size ()I
}

public final class com/facebook/react/views/text/ReactTextViewAccessibilityDelegate$AccessibilityLinks$AccessibleLink {
public fun <init> ()V
public final fun getDescription ()Ljava/lang/String;
public final fun getEnd ()I
public final fun getId ()I
public final fun getStart ()I
public final fun setDescription (Ljava/lang/String;)V
public final fun setEnd (I)V
public final fun setId (I)V
public final fun setStart (I)V
}

public final class com/facebook/react/views/text/ReactTextViewAccessibilityDelegate$Companion {
public final fun resetDelegate (Landroid/view/View;ZI)V
public final fun setDelegate (Landroid/view/View;ZI)V
}

public class com/facebook/react/views/text/ReactTextViewManager : com/facebook/react/views/text/ReactTextAnchorViewManager, com/facebook/react/uimanager/IViewManagerWithChildren {
protected field mReactTextViewManagerCallback Lcom/facebook/react/views/text/ReactTextViewManagerCallback;
public fun <init> ()V
Expand All @@ -7112,6 +7137,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;
public synthetic fun updateViewAccessibility (Landroid/view/View;)V
public fun updateViewAccessibility (Lcom/facebook/react/views/text/ReactTextView;)V
}

public abstract interface class com/facebook/react/views/text/ReactTextViewManagerCallback {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ private static float sanitizeFloatPropertyValue(float value) {
throw new IllegalStateException("Invalid float property value: " + value);
}

private void updateViewAccessibility(@NonNull T view) {
public void updateViewAccessibility(@NonNull T view) {
ReactAccessibilityDelegate.setDelegate(
view, view.isFocusable(), view.getImportantForAccessibility());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -44,12 +37,10 @@
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;

Expand Down Expand Up @@ -79,7 +70,6 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
}

private final View mView;
private final AccessibilityLinks mAccessibilityLinks;

private Handler mHandler;

Expand Down Expand Up @@ -405,7 +395,11 @@ public void handleMessage(Message msg) {
// announcement coalescing.
mView.setFocusable(originalFocus);
ViewCompat.setImportantForAccessibility(mView, originalImportantForAccessibility);
mAccessibilityLinks = (AccessibilityLinks) mView.getTag(R.id.accessibility_links);
}

// The View this delegate is attached to
public View getHostView() {
return mView;
}

@Nullable View mAccessibilityLabelledBy;
Expand Down Expand Up @@ -716,143 +710,17 @@ public static void resetDelegate(

@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
Expand All @@ -861,97 +729,17 @@ protected boolean onPerformActionForVirtualView(
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
Expand Down
Loading

0 comments on commit f60f139

Please sign in to comment.