From 7b5b114d578142d18bf4a7a5279b179a9ac8d958 Mon Sep 17 00:00:00 2001 From: fabriziobertoglio1987 Date: Tue, 29 Mar 2022 13:36:24 -0700 Subject: [PATCH] Making links independently focusable by Talkback (#33215) Summary: This issue fixes [32004][23]. The Pull Request was previously published by [blavalla][10] with [31757][24]. >This is a follow-up on [D23553222 (https://github.com/facebook/react-native/commit/b352e2da8137452f66717cf1cecb2e72abd727d7)][18], which made links functional by using [Talkback's Links menu][1]. We don't often use this as the sole access point for links due to it being more difficult for users to navigate to and easy for users to miss if they don't listen to the full description, including the hint text that announces that links are available. The Implementation of the functionality consists of: Retrieving the accessibility links and triggering the TalkBack Focus over the Text 1. nested Text components with accessibilityRole link are saved as [ReactClickableSpan][17] instances in Android native [TextView][20] ([more info][19]) 1. If the TextView contains any [ClickableSpans][15] (which are [nested Text][14] components with role link), set a view tag and reset the accessibility delegate. 3. Obtain each link description, start, end, and position relative to the parent Text (id) from the Span as an [AccessibilityLink][16] 4. Use the [AccessibilityLink][16] to display TalkBack focus over the link with the `getVirtualViewAt` method (more [info][13]) Implementing ExploreByTouchHelper to detect touches over links and to display TalkBack rectangle around them. 1. ReactAccessibilityDelegate inherits from [ExploreByTouchHelper][12] 2. If the [ReactTextView][21] has an accessibility delegate, trigger ExploreByTouchHelper method [dispatchHoverEvent][22] 3. Implements the methods `getVirtualViewAt` and `onPopulateBoundsForVirtualView`. The two methods implements the following functionalities (more [info][13]): * detecting the TalkBack onPress/focus on nested Text with accessibilityRole="link" * displaying TalkBack rectangle around nested Text with accessibilityRole="link" ## Changelog [Android] [Added] - Make links independently focusable by Talkback Pull Request resolved: https://github.com/facebook/react-native/pull/33215 Test Plan: [1]. User Interacts with links through TalkBack default accessibility menu ([link][1]) [2]. The nested link becomes the next focusable element after the parent element that contains it. ([link][2]) [3]. Testing accessibility examples in pr branch ([link][3]) [4]. Testing accessibility android examples in pr branch ([link][4]) [7]. TalkBack focus moves through links in the correct order from top to bottom (PR Branch with [link.id][25]) ([link to video test][7]) ([discussion][26]) [8]. TalkBack focus does not move through links in the correct order from top to bottom (PR Branch without [link.id][25]) ([link to video test][8]) ([discussion][26]) Test on main branch [5]. Testing accessibility examples in main branch ([link][5]) [6]. Testing accessibility android examples in main branch ([link][6]) [1]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1045593386 [2]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1045593164 [3]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1054900872 [4]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1054918634 [5]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1054888278 [6]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1054891828 [7]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1060073165 [8]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1060098381 [10]: https://github.com/blavalla "blavalla github profile" [12]: https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/com/android/internal/widget/ExploreByTouchHelper.java#L48 "com/android/internal/widget/ExploreByTouchHelper.java#L48" [13]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1046384200 "explanation of getVirtualViewAt and onPopulateBoundsForVirtualView" [14]: https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/text/Spannable.java#L3 "core/java/android/text/Spannable.java#L3" [15]: https://github.com/fabriziobertoglio1987/react-native/blob/561266fc180b96d6337d6c6c5c3323522d66cc44/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java#L70-L71 "react/views/text/ReactTextViewManager.java#L70-L71" [16]: https://github.com/fabriziobertoglio1987/react-native/blob/561266fc180b96d6337d6c6c5c3323522d66cc44/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java#L680-L685 "react/uimanager/ReactAccessibilityDelegate.java#L680-L685" [17]: https://github.com/facebook/react-native/blob/561266fc180b96d6337d6c6c5c3323522d66cc44/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java#L126-L129 "react/views/text/TextLayoutManager.java#L126-L129" [18]: https://github.com/facebook/react-native/commit/b352e2da8137452f66717cf1cecb2e72abd727d7 [19]: https://github.com/facebook/react-native/issues/30375#issuecomment-781494859 "explanation on how nested Text are converted to Android Spans" [20]: https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/widget/TextView.java#L214-L220 "core/java/android/widget/TextView.java#L214-L220" [21]: https://github.com/facebook/react-native/blob/485cf6118b0ab0b59e078b96701b69ae64c4dfb7/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java#L577 "dispatchHoverEvent in ReactTextView" [22]: https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/com/android/internal/widget/ExploreByTouchHelper.java#L120-L138 "dispatchHoverEvent in ExploreByTouchHelper" [23]: https://github.com/facebook/react-native/issues/32004 [24]: https://github.com/facebook/react-native/pull/31757 [25]: https://github.com/fabriziobertoglio1987/react-native/blob/485cf6118b0ab0b59e078b96701b69ae64c4dfb7/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java#L648 "setting link.id in the AccessibilityLink constructor" [26]: https://github.com/facebook/react-native/pull/33215/files/485cf6118b0ab0b59e078b96701b69ae64c4dfb7#r820014411 "comment on role of link.id" Reviewed By: blavalla Differential Revision: D34687371 Pulled By: philIip fbshipit-source-id: 8e63c70e9318ad8d27317bd68497705e595dea0f --- Libraries/Text/Text.js | 1 + Libraries/Text/TextNativeComponent.js | 2 + .../react/uimanager/BaseViewManager.java | 3 +- .../uimanager/ReactAccessibilityDelegate.java | 269 ++++++++++++++++-- .../views/slider/ReactSliderManager.java | 9 +- .../java/com/facebook/react/views/text/BUCK | 1 + .../views/text/ReactBaseTextShadowNode.java | 14 + .../react/views/text/ReactClickableSpan.java | 9 +- .../react/views/text/ReactTextView.java | 20 ++ .../views/text/ReactTextViewManager.java | 17 +- .../react/views/text/TextAttributeProps.java | 5 +- .../react/views/text/TextLayoutManager.java | 11 +- .../text/TextLayoutManagerMapBuffer.java | 11 +- .../react/views/textinput/ReactEditText.java | 9 +- .../main/res/views/uimanager/values/ids.xml | 3 + .../AccessibilityAndroidExample.android.js | 90 +++++- 16 files changed, 423 insertions(+), 51 deletions(-) diff --git a/Libraries/Text/Text.js b/Libraries/Text/Text.js index 35c7d1bc2bf..b27e53400bc 100644 --- a/Libraries/Text/Text.js +++ b/Libraries/Text/Text.js @@ -129,6 +129,7 @@ const Text: React.AbstractComponent< onResponderTerminate(event); } }, + onClick: eventHandlers.onClick, onResponderTerminationRequest: eventHandlers.onResponderTerminationRequest, onStartShouldSetResponder: eventHandlers.onStartShouldSetResponder, diff --git a/Libraries/Text/TextNativeComponent.js b/Libraries/Text/TextNativeComponent.js index b5d5557e38e..ef680f39f43 100644 --- a/Libraries/Text/TextNativeComponent.js +++ b/Libraries/Text/TextNativeComponent.js @@ -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) => mixed, // 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. diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index b48b272d9c2..b349347ca42 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -433,7 +433,8 @@ public abstract class BaseViewManager(); mHandler = new Handler() { @@ -190,6 +204,14 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { 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; @@ -388,18 +410,6 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { 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)) { @@ -445,7 +455,8 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { } } - 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. @@ -453,8 +464,224 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { && (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 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 getFirstSpan(int start, int end, Class 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 mLinks; + + public AccessibilityLinks(ClickableSpan[] spans, Spannable text) { + ArrayList 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; } } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/slider/ReactSliderManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/slider/ReactSliderManager.java index 78203bbaf20..86cbe70bcc3 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/slider/ReactSliderManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/slider/ReactSliderManager.java @@ -16,7 +16,6 @@ import android.view.View; import android.view.ViewGroup; import android.widget.SeekBar; import androidx.annotation.Nullable; -import androidx.core.view.ViewCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReadableMap; @@ -150,7 +149,8 @@ public class ReactSliderManager extends SimpleViewManager @Override protected ReactSlider createViewInstance(ThemedReactContext context) { final ReactSlider slider = new ReactSlider(context, null, STYLE); - ViewCompat.setAccessibilityDelegate(slider, new ReactSliderAccessibilityDelegate()); + ReactSliderAccessibilityDelegate.setDelegate( + slider, slider.isFocusable(), slider.getImportantForAccessibility()); return slider; } @@ -310,6 +310,11 @@ public class ReactSliderManager extends SimpleViewManager } protected class ReactSliderAccessibilityDelegate extends ReactAccessibilityDelegate { + public ReactSliderAccessibilityDelegate( + final View view, boolean originalFocus, int originalImportantForAccessibility) { + super(view, originalFocus, originalImportantForAccessibility); + } + private boolean isSliderAction(int action) { return (action == AccessibilityActionCompat.ACTION_SCROLL_FORWARD.getId()) || (action == AccessibilityActionCompat.ACTION_SCROLL_BACKWARD.getId()) diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/BUCK b/ReactAndroid/src/main/java/com/facebook/react/views/text/BUCK index 4d08db34a28..ea9ebe7947f 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/BUCK @@ -31,5 +31,6 @@ rn_android_library( react_native_target("java/com/facebook/react/uimanager:uimanager"), react_native_target("java/com/facebook/react/uimanager/annotations:annotations"), react_native_target("java/com/facebook/react/views/view:view"), + react_native_target("res:uimanager"), ], ) diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index cdffdb37c71..cbf2967d975 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -35,6 +35,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * {@link ReactShadowNode} abstract class for spannable text nodes. @@ -178,6 +179,10 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode { new SetSpanOperation( start, end, new ReactBackgroundColorSpan(textShadowNode.mBackgroundColor))); } + if (textShadowNode.mIsAccessibilityLink) { + ops.add( + new SetSpanOperation(start, end, new ReactClickableSpan(textShadowNode.getReactTag()))); + } float effectiveLetterSpacing = textAttributes.getEffectiveLetterSpacing(); if (!Float.isNaN(effectiveLetterSpacing) && (parentTextAttributes == null @@ -319,6 +324,7 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode { protected int mColor; protected boolean mIsBackgroundColorSet = false; protected int mBackgroundColor; + protected boolean mIsAccessibilityLink = false; protected int mNumberOfLines = UNSET; protected int mTextAlign = Gravity.NO_GRAVITY; @@ -490,6 +496,14 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode { } } + @ReactProp(name = ViewProps.ACCESSIBILITY_ROLE) + public void setIsAccessibilityLink(@Nullable String accessibilityRole) { + if (isVirtual()) { + mIsAccessibilityLink = Objects.equals(accessibilityRole, "link"); + markUpdated(); + } + } + @ReactProp(name = ViewProps.FONT_FAMILY) public void setFontFamily(@Nullable String fontFamily) { mFontFamily = fontFamily; diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactClickableSpan.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactClickableSpan.java index bff24563aa1..3b71d0b1366 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactClickableSpan.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactClickableSpan.java @@ -39,11 +39,9 @@ import com.facebook.react.views.view.ViewGroupClickEvent; class ReactClickableSpan extends ClickableSpan implements ReactSpan { private final int mReactTag; - private final int mForegroundColor; - ReactClickableSpan(int reactTag, int foregroundColor) { + ReactClickableSpan(int reactTag) { mReactTag = reactTag; - mForegroundColor = foregroundColor; } @Override @@ -59,9 +57,8 @@ class ReactClickableSpan extends ClickableSpan implements ReactSpan { @Override public void updateDrawState(@NonNull TextPaint ds) { - super.updateDrawState(ds); - ds.setColor(mForegroundColor); - ds.setUnderlineText(false); + // no-op to make sure we don't change the link color or add an underline by default, as the + // superclass does. } public int getReactTag() { diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index c71a130419b..7e745556be5 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -19,11 +19,15 @@ import android.text.TextUtils; import android.text.method.LinkMovementMethod; import android.text.util.Linkify; import android.view.Gravity; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatTextView; import androidx.appcompat.widget.TintContextWrapper; +import androidx.core.view.AccessibilityDelegateCompat; +import androidx.core.view.ViewCompat; +import androidx.customview.widget.ExploreByTouchHelper; import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; import com.facebook.react.bridge.Arguments; @@ -551,4 +555,20 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie public void setLinkifyMask(int mask) { mLinkifyMaskType = mask; } + + @Override + protected boolean dispatchHoverEvent(MotionEvent event) { + // if this view has an accessibility delegate set, and that delegate supports virtual view + // children (used for links), pass the hover event along to it so that touching and holding on + // this text will properly move focus to the virtual children. + if (ViewCompat.hasAccessibilityDelegate(this)) { + AccessibilityDelegateCompat delegate = ViewCompat.getAccessibilityDelegate(this); + if (delegate instanceof ExploreByTouchHelper) { + return ((ExploreByTouchHelper) delegate).dispatchHoverEvent(event) + || super.dispatchHoverEvent(event); + } + } + + return super.dispatchHoverEvent(event); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java index 9c043587b79..6aad6e761e3 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java @@ -10,6 +10,7 @@ package com.facebook.react.views.text; import android.content.Context; import android.text.Spannable; import androidx.annotation.Nullable; +import com.facebook.react.R; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableNativeMap; import com.facebook.react.common.MapBuilder; @@ -18,6 +19,7 @@ import com.facebook.react.common.mapbuffer.ReadableMapBuffer; import com.facebook.react.config.ReactFeatureFlags; 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; @@ -57,11 +59,24 @@ public class ReactTextViewManager @Override public void updateExtraData(ReactTextView view, Object extraData) { ReactTextUpdate update = (ReactTextUpdate) extraData; + Spannable spannable = update.getText(); if (update.containsImages()) { - Spannable spannable = update.getText(); TextInlineImageSpan.possiblyUpdateInlineImageSpans(spannable, view); } view.setText(update); + + // If this text view contains any clickable spans, set a view tag and reset the accessibility + // delegate so that these can be picked up by the accessibility system. + ReactClickableSpan[] clickableSpans = + spannable.getSpans(0, update.getText().length(), ReactClickableSpan.class); + + if (clickableSpans.length > 0) { + view.setTag( + R.id.accessibility_links, + new ReactAccessibilityDelegate.AccessibilityLinks(clickableSpans, spannable)); + ReactAccessibilityDelegate.resetDelegate( + view, view.isFocusable(), view.getImportantForAccessibility()); + } } @Override diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java index ed75ed5a5d9..e3f15b9ebe6 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.java @@ -102,6 +102,7 @@ public class TextAttributeProps { protected @Nullable ReactAccessibilityDelegate.AccessibilityRole mAccessibilityRole = null; protected boolean mIsAccessibilityRoleSet = false; + protected boolean mIsAccessibilityLink = false; protected int mFontStyle = UNSET; protected int mFontWeight = UNSET; @@ -543,9 +544,11 @@ public class TextAttributeProps { private void setAccessibilityRole(@Nullable String accessibilityRole) { if (accessibilityRole != null) { - mIsAccessibilityRoleSet = accessibilityRole != null; + mIsAccessibilityRoleSet = true; mAccessibilityRole = ReactAccessibilityDelegate.AccessibilityRole.fromValue(accessibilityRole); + mIsAccessibilityLink = + mAccessibilityRole.equals(ReactAccessibilityDelegate.AccessibilityRole.LINK); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java index 5c3ec6644d5..c8bdae6949d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java @@ -33,7 +33,6 @@ import com.facebook.react.bridge.WritableArray; import com.facebook.react.common.build.ReactBuildConfig; import com.facebook.react.config.ReactFeatureFlags; import com.facebook.react.uimanager.PixelUtil; -import com.facebook.react.uimanager.ReactAccessibilityDelegate; import com.facebook.react.uimanager.ReactStylesDiffMap; import com.facebook.react.uimanager.ViewProps; import com.facebook.yoga.YogaConstants; @@ -126,12 +125,10 @@ public class TextLayoutManager { sb.length(), new TextInlineViewPlaceholderSpan(reactTag, (int) width, (int) height))); } else if (end >= start) { - if (ReactAccessibilityDelegate.AccessibilityRole.LINK.equals( - textAttributes.mAccessibilityRole)) { - ops.add( - new SetSpanOperation( - start, end, new ReactClickableSpan(reactTag, textAttributes.mColor))); - } else if (textAttributes.mIsColorSet) { + if (textAttributes.mIsAccessibilityLink) { + ops.add(new SetSpanOperation(start, end, new ReactClickableSpan(reactTag))); + } + if (textAttributes.mIsColorSet) { ops.add( new SetSpanOperation( start, end, new ReactForegroundColorSpan(textAttributes.mColor))); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java index b73c0e5fa0b..81b8af845dc 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java @@ -30,7 +30,6 @@ import com.facebook.react.bridge.WritableArray; import com.facebook.react.common.build.ReactBuildConfig; import com.facebook.react.common.mapbuffer.ReadableMapBuffer; import com.facebook.react.uimanager.PixelUtil; -import com.facebook.react.uimanager.ReactAccessibilityDelegate; import com.facebook.yoga.YogaConstants; import com.facebook.yoga.YogaMeasureMode; import com.facebook.yoga.YogaMeasureOutput; @@ -139,12 +138,10 @@ public class TextLayoutManagerMapBuffer { sb.length(), new TextInlineViewPlaceholderSpan(reactTag, (int) width, (int) height))); } else if (end >= start) { - if (ReactAccessibilityDelegate.AccessibilityRole.LINK.equals( - textAttributes.mAccessibilityRole)) { - ops.add( - new SetSpanOperation( - start, end, new ReactClickableSpan(reactTag, textAttributes.mColor))); - } else if (textAttributes.mIsColorSet) { + if (textAttributes.mIsAccessibilityLink) { + ops.add(new SetSpanOperation(start, end, new ReactClickableSpan(reactTag))); + } + if (textAttributes.mIsColorSet) { ops.add( new SetSpanOperation( start, end, new ReactForegroundColorSpan(textAttributes.mColor))); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index 360de688d54..9098e98b25d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java @@ -154,9 +154,9 @@ public class ReactEditText extends AppCompatEditText setLayerType(View.LAYER_TYPE_SOFTWARE, null); } - ViewCompat.setAccessibilityDelegate( - this, - new ReactAccessibilityDelegate() { + ReactAccessibilityDelegate editTextAccessibilityDelegate = + new ReactAccessibilityDelegate( + this, this.isFocusable(), this.getImportantForAccessibility()) { @Override public boolean performAccessibilityAction(View host, int action, Bundle args) { if (action == AccessibilityNodeInfo.ACTION_CLICK) { @@ -172,7 +172,8 @@ public class ReactEditText extends AppCompatEditText } return super.performAccessibilityAction(host, action, args); } - }); + }; + ViewCompat.setAccessibilityDelegate(this, editTextAccessibilityDelegate); } @Override diff --git a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 5c6c6d354d5..b9e9b732bb3 100644 --- a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -27,6 +27,8 @@ + + @@ -39,3 +41,4 @@ + diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js index 12754e4e3ec..35e1be366ca 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js @@ -13,7 +13,13 @@ const React = require('react'); import RNTesterBlock from '../../components/RNTesterBlock'; import RNTesterPage from '../../components/RNTesterPage'; -import {StyleSheet, Text, View, TouchableWithoutFeedback} from 'react-native'; +import { + Alert, + StyleSheet, + Text, + View, + TouchableWithoutFeedback, +} from 'react-native'; const importantForAccessibilityValues = [ 'auto', @@ -143,6 +149,77 @@ class AccessibilityAndroidExample extends React.Component< + + + In the following example, the words "test", "inline links", "another + link", and "link that spans multiple lines because the text is so + long", should each be independantly focusable elements, announced as + their content followed by ", Link". + + + They should be focused in order from top to bottom *after* the + contents of the entire paragraph. + + + Focusing on the paragraph itself should also announce that there are + "links avaialable", and opening Talkback's links menu should show + these same links. + + + Clicking on each link, or selecting the link From Talkback's links + menu should trigger an alert. + + + The links that wraps to multiple lines will intentionally only draw + a focus outline around the first line, but using the "explore by + touch" tap-and-drag gesture should move focus to this link even if + the second line is touched. + + + Using the "Explore by touch" gesture and touching an area that is + *not* a link should move focus to the entire paragraph. + + Example + + This is a{' '} + { + Alert.alert('pressed test'); + }}> + test + {' '} + of{' '} + { + Alert.alert('pressed Inline Links'); + }}> + inline links + {' '} + in React Native. Here's{' '} + { + Alert.alert('pressed another link'); + }}> + another link + + . Here is a{' '} + { + Alert.alert('pressed long link'); + }}> + link that spans multiple lines because the text is so long. + + This sentence has no links in it. + + ); } @@ -167,6 +244,17 @@ const styles = StyleSheet.create({ padding: 10, height: 150, }, + paragraph: { + paddingBottom: 10, + }, + link: { + color: 'blue', + fontWeight: 'bold', + }, + exampleTitle: { + fontWeight: 'bold', + fontSize: 20, + }, }); exports.title = 'AccessibilityAndroid';