Skip to content

Commit d65d846

Browse files
joevilchesfacebook-github-bot
authored andcommitted
Move text-specific a11y logic in ReactAccessibilityDelegate to subclass (facebook#49377)
Summary: Pull Request resolved: facebook#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
1 parent 50878c2 commit d65d846

File tree

5 files changed

+319
-239
lines changed

5 files changed

+319
-239
lines changed

packages/react-native/ReactAndroid/api/ReactAndroid.api

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3822,6 +3822,7 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo
38223822
public fun setTranslateY (Landroid/view/View;F)V
38233823
public fun setViewState (Landroid/view/View;Lcom/facebook/react/bridge/ReadableMap;)V
38243824
public fun setZIndex (Landroid/view/View;F)V
3825+
protected fun updateViewAccessibility (Landroid/view/View;)V
38253826
}
38263827

38273828
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/
41064107
public fun <init> (Landroid/view/View;ZI)V
41074108
public static fun createNodeInfoFromView (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;
41084109
public fun getAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat;
4109-
protected fun getFirstSpan (IILjava/lang/Class;)Ljava/lang/Object;
4110+
protected fun getHostView ()Landroid/view/View;
41104111
public static fun getTalkbackDescription (Landroid/view/View;Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;)Ljava/lang/CharSequence;
41114112
protected fun getVirtualViewAt (FF)I
41124113
protected fun getVisibleVirtualViews (Ljava/util/List;)V
@@ -4124,13 +4125,7 @@ public class com/facebook/react/uimanager/ReactAccessibilityDelegate : androidx/
41244125
public static fun resetDelegate (Landroid/view/View;ZI)V
41254126
public static fun setDelegate (Landroid/view/View;ZI)V
41264127
public static fun setRole (Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityRole;Landroid/content/Context;)V
4127-
}
4128-
4129-
public class com/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks {
4130-
public fun <init> ([Landroid/text/style/ClickableSpan;Landroid/text/Spannable;)V
4131-
public fun getLinkById (I)Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
4132-
public fun getLinkBySpanPos (II)Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
4133-
public fun size ()I
4128+
public fun superGetAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat;
41344129
}
41354130

41364131
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
69226917
public fun updateExtraData (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/Object;)V
69236918
public synthetic fun updateState (Landroid/view/View;Lcom/facebook/react/uimanager/ReactStylesDiffMap;Lcom/facebook/react/uimanager/StateWrapper;)Ljava/lang/Object;
69246919
public fun updateState (Lcom/facebook/react/views/text/ReactTextView;Lcom/facebook/react/uimanager/ReactStylesDiffMap;Lcom/facebook/react/uimanager/StateWrapper;)Ljava/lang/Object;
6920+
protected synthetic fun updateViewAccessibility (Landroid/view/View;)V
6921+
protected fun updateViewAccessibility (Lcom/facebook/react/views/text/ReactTextView;)V
69256922
}
69266923

69276924
public abstract interface class com/facebook/react/views/text/ReactTextViewManagerCallback {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,7 @@ private static float sanitizeFloatPropertyValue(float value) {
626626
throw new IllegalStateException("Invalid float property value: " + value);
627627
}
628628

629-
private void updateViewAccessibility(@NonNull T view) {
629+
protected void updateViewAccessibility(@NonNull T view) {
630630
ReactAccessibilityDelegate.setDelegate(
631631
view, view.isFocusable(), view.getImportantForAccessibility());
632632
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java

Lines changed: 15 additions & 227 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,15 @@
88
package com.facebook.react.uimanager;
99

1010
import android.content.Context;
11-
import android.graphics.Paint;
1211
import android.graphics.Rect;
1312
import android.os.Bundle;
1413
import android.os.Handler;
1514
import android.os.Message;
16-
import android.text.Layout;
17-
import android.text.Spannable;
18-
import android.text.Spanned;
1915
import android.text.TextUtils;
20-
import android.text.style.AbsoluteSizeSpan;
21-
import android.text.style.ClickableSpan;
2216
import android.view.View;
2317
import android.view.ViewGroup;
2418
import android.view.accessibility.AccessibilityEvent;
2519
import android.widget.EditText;
26-
import android.widget.TextView;
2720
import androidx.annotation.NonNull;
2821
import androidx.annotation.Nullable;
2922
import androidx.core.view.ViewCompat;
@@ -44,12 +37,10 @@
4437
import com.facebook.react.bridge.ReadableType;
4538
import com.facebook.react.bridge.UIManager;
4639
import com.facebook.react.bridge.WritableMap;
47-
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole;
4840
import com.facebook.react.uimanager.common.ViewUtil;
4941
import com.facebook.react.uimanager.events.Event;
5042
import com.facebook.react.uimanager.events.EventDispatcher;
5143
import com.facebook.react.uimanager.util.ReactFindViewUtil;
52-
import java.util.ArrayList;
5344
import java.util.HashMap;
5445
import java.util.List;
5546

@@ -79,7 +70,6 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
7970
}
8071

8172
private final View mView;
82-
private final AccessibilityLinks mAccessibilityLinks;
8373

8474
private Handler mHandler;
8575

@@ -405,7 +395,11 @@ public void handleMessage(Message msg) {
405395
// announcement coalescing.
406396
mView.setFocusable(originalFocus);
407397
ViewCompat.setImportantForAccessibility(mView, originalImportantForAccessibility);
408-
mAccessibilityLinks = (AccessibilityLinks) mView.getTag(R.id.accessibility_links);
398+
}
399+
400+
// The View this delegate is attached to
401+
protected View getHostView() {
402+
return mView;
409403
}
410404

411405
@Nullable View mAccessibilityLabelledBy;
@@ -716,143 +710,17 @@ public static void resetDelegate(
716710

717711
@Override
718712
protected int getVirtualViewAt(float x, float y) {
719-
if (mAccessibilityLinks == null
720-
|| mAccessibilityLinks.size() == 0
721-
|| !(mView instanceof TextView)) {
722-
return INVALID_ID;
723-
}
724-
725-
TextView textView = (TextView) mView;
726-
if (!(textView.getText() instanceof Spanned)) {
727-
return INVALID_ID;
728-
}
729-
730-
Layout layout = textView.getLayout();
731-
if (layout == null) {
732-
return INVALID_ID;
733-
}
734-
735-
x -= textView.getTotalPaddingLeft();
736-
y -= textView.getTotalPaddingTop();
737-
x += textView.getScrollX();
738-
y += textView.getScrollY();
739-
740-
int line = layout.getLineForVertical((int) y);
741-
int charOffset = layout.getOffsetForHorizontal(line, x);
742-
743-
ClickableSpan clickableSpan = getFirstSpan(charOffset, charOffset, ClickableSpan.class);
744-
if (clickableSpan == null) {
745-
return INVALID_ID;
746-
}
747-
748-
Spanned spanned = (Spanned) textView.getText();
749-
int start = spanned.getSpanStart(clickableSpan);
750-
int end = spanned.getSpanEnd(clickableSpan);
751-
752-
final AccessibilityLinks.AccessibleLink link = mAccessibilityLinks.getLinkBySpanPos(start, end);
753-
return link != null ? link.id : INVALID_ID;
713+
return INVALID_ID;
754714
}
755715

756716
@Override
757-
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
758-
if (mAccessibilityLinks == null) {
759-
return;
760-
}
761-
762-
for (int i = 0; i < mAccessibilityLinks.size(); i++) {
763-
virtualViewIds.add(i);
764-
}
765-
}
717+
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {}
766718

767719
@Override
768720
protected void onPopulateNodeForVirtualView(
769721
int virtualViewId, @NonNull AccessibilityNodeInfoCompat node) {
770-
// If we get an invalid virtualViewId for some reason (which is known to happen in API 19 and
771-
// below), return an "empty" node to prevent from crashing. This will never be presented to
772-
// the user, as Talkback filters out nodes with no content to announce.
773-
if (mAccessibilityLinks == null) {
774-
node.setContentDescription("");
775-
node.setBoundsInParent(new Rect(0, 0, 1, 1));
776-
return;
777-
}
778-
779-
final AccessibilityLinks.AccessibleLink accessibleTextSpan =
780-
mAccessibilityLinks.getLinkById(virtualViewId);
781-
if (accessibleTextSpan == null) {
782-
node.setContentDescription("");
783-
node.setBoundsInParent(new Rect(0, 0, 1, 1));
784-
return;
785-
}
786-
787-
// NOTE: The span may not actually have visible bounds within its parent,
788-
// due to line limits, etc.
789-
final Rect bounds = getBoundsInParent(accessibleTextSpan);
790-
if (bounds == null) {
791-
node.setContentDescription("");
792-
node.setBoundsInParent(new Rect(0, 0, 1, 1));
793-
return;
794-
}
795-
796-
node.setContentDescription(accessibleTextSpan.description);
797-
node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
798-
node.setBoundsInParent(bounds);
799-
node.setRoleDescription(mView.getResources().getString(R.string.link_description));
800-
node.setClassName(AccessibilityRole.getValue(AccessibilityRole.BUTTON));
801-
}
802-
803-
private Rect getBoundsInParent(AccessibilityLinks.AccessibleLink accessibleLink) {
804-
// This view is not a text view, so return the entire views bounds.
805-
if (!(mView instanceof TextView)) {
806-
return new Rect(0, 0, mView.getWidth(), mView.getHeight());
807-
}
808-
809-
TextView textView = (TextView) mView;
810-
Layout textViewLayout = textView.getLayout();
811-
if (textViewLayout == null) {
812-
return new Rect(0, 0, textView.getWidth(), textView.getHeight());
813-
}
814-
815-
double startOffset = accessibleLink.start;
816-
double endOffset = accessibleLink.end;
817-
818-
// Ensure the link hasn't been ellipsized away; in such cases,
819-
// getPrimaryHorizontal will crash (and the link isn't rendered anyway).
820-
int startOffsetLineNumber = textViewLayout.getLineForOffset((int) startOffset);
821-
int lineEndOffset = textViewLayout.getLineEnd(startOffsetLineNumber);
822-
if (startOffset > lineEndOffset) {
823-
return null;
824-
}
825-
826-
Rect rootRect = new Rect();
827-
828-
double startXCoordinates = textViewLayout.getPrimaryHorizontal((int) startOffset);
829-
830-
final Paint paint = new Paint();
831-
AbsoluteSizeSpan sizeSpan =
832-
getFirstSpan(accessibleLink.start, accessibleLink.end, AbsoluteSizeSpan.class);
833-
float textSize = sizeSpan != null ? sizeSpan.getSize() : textView.getTextSize();
834-
paint.setTextSize(textSize);
835-
int textWidth = (int) Math.ceil(paint.measureText(accessibleLink.description));
836-
837-
int endOffsetLineNumber = textViewLayout.getLineForOffset((int) endOffset);
838-
boolean isMultiline = startOffsetLineNumber != endOffsetLineNumber;
839-
textViewLayout.getLineBounds(startOffsetLineNumber, rootRect);
840-
841-
int verticalOffset = textView.getScrollY() + textView.getTotalPaddingTop();
842-
rootRect.top += verticalOffset;
843-
rootRect.bottom += verticalOffset;
844-
rootRect.left += startXCoordinates + textView.getTotalPaddingLeft() - textView.getScrollX();
845-
846-
// The bounds for multi-line strings should *only* include the first line. This is because for
847-
// API 25 and below, Talkback's click is triggered at the center point of these bounds, and if
848-
// that center point is outside the spannable, it will click on something else. There is no
849-
// harm in not outlining the wrapped part of the string, as the text for the whole string will
850-
// be read regardless of the bounding box.
851-
if (isMultiline) {
852-
return new Rect(rootRect.left, rootRect.top, rootRect.right, rootRect.bottom);
853-
}
854-
855-
return new Rect(rootRect.left, rootRect.top, rootRect.left + textWidth, rootRect.bottom);
722+
node.setContentDescription("");
723+
node.setBoundsInParent(new Rect(0, 0, 1, 1));
856724
}
857725

858726
@Override
@@ -861,97 +729,17 @@ protected boolean onPerformActionForVirtualView(
861729
return false;
862730
}
863731

864-
protected @Nullable <T> T getFirstSpan(int start, int end, Class<T> classType) {
865-
if (!(mView instanceof TextView) || !(((TextView) mView).getText() instanceof Spanned)) {
866-
return null;
867-
}
868-
869-
Spanned spanned = (Spanned) ((TextView) mView).getText();
870-
T[] spans = spanned.getSpans(start, end, classType);
871-
return spans.length > 0 ? spans[0] : null;
872-
}
873-
874-
public static class AccessibilityLinks {
875-
private final List<AccessibleLink> mLinks;
876-
877-
public AccessibilityLinks(ClickableSpan[] spans, Spannable text) {
878-
ArrayList<AccessibleLink> links = new ArrayList<>();
879-
for (int i = 0; i < spans.length; i++) {
880-
ClickableSpan span = spans[i];
881-
int start = text.getSpanStart(span);
882-
int end = text.getSpanEnd(span);
883-
// zero length spans, and out of range spans should not be included.
884-
if (start == end || start < 0 || end < 0 || start > text.length() || end > text.length()) {
885-
continue;
886-
}
887-
888-
final AccessibleLink link = new AccessibleLink();
889-
link.description = text.subSequence(start, end).toString();
890-
link.start = start;
891-
link.end = end;
892-
893-
// ID is the reverse of what is expected, since the ClickableSpans are returned in reverse
894-
// order due to being added in reverse order. If we don't do this, focus will move to the
895-
// last link first and move backwards.
896-
//
897-
// If this approach becomes unreliable, we should instead look at their start position and
898-
// order them manually.
899-
link.id = spans.length - 1 - i;
900-
links.add(link);
901-
}
902-
mLinks = links;
903-
}
904-
905-
@Nullable
906-
public AccessibleLink getLinkById(int id) {
907-
for (AccessibleLink link : mLinks) {
908-
if (link.id == id) {
909-
return link;
910-
}
911-
}
912-
913-
return null;
914-
}
915-
916-
@Nullable
917-
public AccessibleLink getLinkBySpanPos(int start, int end) {
918-
for (AccessibleLink link : mLinks) {
919-
if (link.start == start && link.end == end) {
920-
return link;
921-
}
922-
}
923-
924-
return null;
925-
}
926-
927-
public int size() {
928-
return mLinks.size();
929-
}
930-
931-
private static class AccessibleLink {
932-
public String description;
933-
public int start;
934-
public int end;
935-
public int id;
936-
}
937-
}
938-
939732
@Override
940733
public @Nullable AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) {
941-
// Only set a NodeProvider if we have virtual views, otherwise just return null here so that
942-
// we fall back to the View class's default behavior. If we don't do this, then Views with
943-
// no virtual children will fall back to using ExploreByTouchHelper's onPopulateNodeForHost
944-
// method to populate their AccessibilityNodeInfo, which defaults to doing nothing, so no
945-
// AccessibilityNodeInfo will be created. Alternatively, we could override
946-
// onPopulateNodeForHost instead, and have it create an AccessibilityNodeInfo for the host
947-
// but this is what the default View class does by itself, so we may as well defer to it.
948-
if (mAccessibilityLinks != null) {
949-
return super.getAccessibilityNodeProvider(host);
950-
}
951-
952734
return null;
953735
}
954736

737+
// This exists so classes that extend this can properly call super's impl of this method while
738+
// still being able to override it properly for this class
739+
public @Nullable AccessibilityNodeProviderCompat superGetAccessibilityNodeProvider(View host) {
740+
return super.getAccessibilityNodeProvider(host);
741+
}
742+
955743
/**
956744
* Determines if the supplied {@link View} and {@link AccessibilityNodeInfoCompat} has any
957745
* children which are not independently accessibility focusable and also have a spoken

0 commit comments

Comments
 (0)