Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Making links independently focusable by Talkback #33215

Closed
1 change: 1 addition & 0 deletions Libraries/Text/Text.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const Text: React.AbstractComponent<
onResponderTerminate(event);
}
},
onClick: eventHandlers.onClick,
onResponderTerminationRequest:
eventHandlers.onResponderTerminationRequest,
onStartShouldSetResponder: eventHandlers.onStartShouldSetResponder,
Expand Down
2 changes: 2 additions & 0 deletions Libraries/Text/TextNativeComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import {type HostComponent} from '../Renderer/shims/ReactNativeTypes';
import createReactNativeComponentClass from '../Renderer/shims/createReactNativeComponentClass';
import {type ProcessedColorValue} from '../StyleSheet/processColor';
import {type TextProps} from './TextProps';
import {type PressEvent} from '../Types/CoreEventTypes';

type NativeTextProps = $ReadOnly<{
...TextProps,
isHighlighted?: ?boolean,
selectionColor?: ?ProcessedColorValue,
onClick?: ?(event: PressEvent) => void,
// This is only needed for platforms that optimize text hit testing, e.g.,
// react-native-windows. It can be used to only hit test virtual text spans
// that have pressable events attached to them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,8 @@ private static void resetTransformProperty(@NonNull View view) {
}

private void updateViewAccessibility(@NonNull T view) {
ReactAccessibilityDelegate.setDelegate(view);
ReactAccessibilityDelegate.setDelegate(
view, view.isFocusable(), view.getImportantForAccessibility());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,26 @@
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.SpannableString;
import android.text.style.URLSpan;
import android.text.Layout;
import android.text.Spannable;
import android.text.Spanned;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ClickableSpan;
import android.view.View;
import android.view.accessibility.AccessibilityEvent;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat;
import androidx.customview.widget.ExploreByTouchHelper;
import com.facebook.react.R;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Dynamic;
Expand All @@ -35,14 +42,16 @@
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.events.Event;
import com.facebook.react.uimanager.events.EventDispatcher;
import java.util.ArrayList;
import com.facebook.react.uimanager.util.ReactFindViewUtil;
import java.util.HashMap;
import java.util.List;

/**
* Utility class that handles the addition of a "role" for accessibility to either a View or
* AccessibilityNodeInfo.
*/
public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat {
public class ReactAccessibilityDelegate extends ExploreByTouchHelper {

private static final String TAG = "ReactAccessibilityDelegate";
public static final String TOP_ACCESSIBILITY_ACTION_EVENT = "topAccessibilityAction";
Expand All @@ -59,6 +68,9 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat {
sActionIdMap.put("decrement", AccessibilityActionCompat.ACTION_SCROLL_BACKWARD.getId());
}

private final View mView;
private final AccessibilityLinks mAccessibilityLinks;

private Handler mHandler;

/**
Expand Down Expand Up @@ -179,8 +191,10 @@ public static AccessibilityRole fromValue(@Nullable String value) {
private static final String STATE_SELECTED = "selected";
private static final String STATE_CHECKED = "checked";

public ReactAccessibilityDelegate() {
super();
public ReactAccessibilityDelegate(
final View view, boolean originalFocus, int originalImportantForAccessibility) {
super(view);
mView = view;
mAccessibilityActionsMap = new HashMap<Integer, String>();
mHandler =
new Handler() {
Expand All @@ -190,6 +204,14 @@ public void handleMessage(Message msg) {
host.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
}
};

// We need to reset these two properties, as ExploreByTouchHelper sets focusable to "true" and
// importantForAccessibility to "Yes" (if it is Auto). If we don't reset these it would force
// every element that has this delegate attached to be focusable, and not allow for
// announcement coalescing.
mView.setFocusable(originalFocus);
ViewCompat.setImportantForAccessibility(mView, originalImportantForAccessibility);
mAccessibilityLinks = (AccessibilityLinks) mView.getTag(R.id.accessibility_links);
}

@Nullable View mAccessibilityLabelledBy;
Expand Down Expand Up @@ -388,18 +410,6 @@ public static void setRole(
nodeInfo.setClassName(AccessibilityRole.getValue(role));
if (role.equals(AccessibilityRole.LINK)) {
nodeInfo.setRoleDescription(context.getString(R.string.link_description));

if (nodeInfo.getContentDescription() != null) {
SpannableString spannable = new SpannableString(nodeInfo.getContentDescription());
spannable.setSpan(new URLSpan(""), 0, spannable.length(), 0);
nodeInfo.setContentDescription(spannable);
}

if (nodeInfo.getText() != null) {
SpannableString spannable = new SpannableString(nodeInfo.getText());
spannable.setSpan(new URLSpan(""), 0, spannable.length(), 0);
nodeInfo.setText(spannable);
}
} else if (role.equals(AccessibilityRole.IMAGE)) {
nodeInfo.setRoleDescription(context.getString(R.string.image_description));
} else if (role.equals(AccessibilityRole.IMAGEBUTTON)) {
Expand Down Expand Up @@ -445,16 +455,233 @@ public static void setRole(
}
}

public static void setDelegate(final View view) {
public static void setDelegate(
final View view, boolean originalFocus, int originalImportantForAccessibility) {
// 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)) {
ViewCompat.setAccessibilityDelegate(view, new ReactAccessibilityDelegate());
|| view.getTag(R.id.react_test_id) != null
|| view.getTag(R.id.accessibility_links) != null)) {
ViewCompat.setAccessibilityDelegate(
view,
new ReactAccessibilityDelegate(view, originalFocus, originalImportantForAccessibility));
}
}

// Explicitly re-set the delegate, even if one has already been set.
public static void resetDelegate(
final View view, boolean originalFocus, int originalImportantForAccessibility) {
ViewCompat.setAccessibilityDelegate(
view,
new ReactAccessibilityDelegate(view, originalFocus, originalImportantForAccessibility));
}

@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;
}

@Override
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
if (mAccessibilityLinks == null) {
return;
}

for (int i = 0; i < mAccessibilityLinks.size(); i++) {
virtualViewIds.add(i);
}
}

@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;
}

node.setContentDescription(accessibleTextSpan.description);
node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
node.setBoundsInParent(getBoundsInParent(accessibleTextSpan));
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());
}

Rect rootRect = new Rect();

double startOffset = accessibleLink.start;
double endOffset = accessibleLink.end;
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 startOffsetLineNumber = textViewLayout.getLineForOffset((int) startOffset);
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);
}

@Override
protected boolean onPerformActionForVirtualView(
int virtualViewId, int action, @Nullable Bundle arguments) {
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);
Comment on lines +642 to +649
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you verify that this is still necessary? At the time this was written, if this didn't happen focus would move to the last link in the paragraph on the first swipe, and then the next swipe would start navigating through the links from the bottom to the top. I'm not sure if this is still necessary or not, as it seemed like it may have been an unintentional side effect of the way we create these ClickableSpans, and that code may have changed since then.

A video showing a focus on a paragraph and then a swipe through the links both with this code and without it should be enough.

This may also be impacting the order that the links appear in the "Links" menu, although their order there isn't as important, as it's out of the context of the text itself. Ideally these would still match though and the first link in the text would be first in the list as well as the first swipe.

Copy link
Contributor Author

@fabOnReact fabOnReact Mar 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you verify that this is still necessary? At the time this was written, if this didn't happen focus would move to the last link in the paragraph on the first swipe, and then the next swipe would start navigating through the links from the bottom to the top. I'm not sure if this is still necessary or not, as it seemed like it may have been an unintentional side effect of the way we create these ClickableSpans, and that code may have changed since then.

Thanks @blavalla Yes. It is still necessary.

This may also be impacting the order that the links appear in the "Links" menu, although their order there isn't as important, as it's out of the context of the text itself. Ideally these would still match though and the first link in the text would be first in the list as well as the first swipe.

I run a test of the functionality on the main branch and the PR branch.

PR branch Main branch

I remain available for improvements. Thanks

A video showing a focus on a paragraph and then a swipe through the links both with this code and without it should be enough.

I included below the test cases and updated the summary of the Pull Request.

TalkBack focus moves through links IN THE CORRECT ORDER from top to bottom (PR Branch with link.id)

Testing with the link.id in AccessibilityLink (discussion)

// 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);

Expected Result:
Swiping move TalkBack focus in this order:

  1. Parent Text This is a test of inline links in React Native. Here's another link. Here is a link that spans multiple lines because the text is so long. This sentence has no links in it. links available
  2. Nested Texts in order from top to bottom (test, inline links, another link, link that spans multiple lines...)

Links are displayed in the above order in the TalkBack menu

Actual Results:

RESULT 1 (SUCCESS) - Swiping moves TalkBack focus in the correct order

  1. Parent Text This is a test of inline links in React Native. Here's another link. Here is a link that spans multiple lines because the text is so long. This sentence has no links in it. links available
  2. Nested Texts in order from top to bottom
2022-03-07.09-11-19.mp4

RESULT 2 (FAIL) - Links are NOT displayed in the correct order in the TalkBack menu

2022-03-07.09-13-23.mp4

TalkBack focus does NOT move through links in the correct order from top to bottom (PR Branch without link.id)

Testing without the link.id in AccessibilityLink (discussion)

// 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);

Expected Result:
Swiping move TalkBack focus in this order:

  1. Parent Text This is a test of inline links in React Native. Here's another link. Here is a link that spans multiple lines because the text is so long. This sentence has no links in it. links available
  2. Nested Texts in order from top to bottom (test, inline links, another link, link that spans multiple lines...)

Links are displayed in the above order in the TalkBack menu

Actual Results:

RESULT 1 (FAIL) - Swiping moves TalkBack focus in this wrong order

  1. Parent Text This is a test of inline links in React Native. Here's another link. Here is a link that spans multiple lines because the text is so long. This sentence has no links in it. links available
  2. Swipe right or down moves the focus to the last link link that spans multiple lines because the text is too long.
2022-03-07.08-07-55.mp4
RESULT 2 (FAIL) - Links are NOT displayed in the correct order in the TalkBack menu

2022-03-07.08-17-20.mp4

}
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;
}
}
}
Loading