From 0e06e4be10b14f83fe837c2626835df126928217 Mon Sep 17 00:00:00 2001 From: Brett Lavalla Date: Mon, 19 Jul 2021 10:40:30 -0700 Subject: [PATCH 1/5] Make links independently focusable by Talkback (#31757) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/31757 This follows up on D23553222 (https://github.com/facebook/react-native/commit/b352e2da8137452f66717cf1cecb2e72abd727d7), which made links functional by using Talkback's Links menu. 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 entire description, including the hint text that announces that links are available. Instead, we generally allow links to be focusable after the main text that contains them is focused. This diff adds this functionality for both Paper and Fabric, and also retains the existing Links menu functionality as well, for users who prefer to use it. Reviewed By: yungsters Differential Revision: D28691177 fbshipit-source-id: 55e675cf84df5dda3b15d81a7f9bf1f071006bd2 --- Libraries/Text/Text.js | 1 + Libraries/Text/TextNativeComponent.js | 2 + ReactAndroid/build.gradle | 3 +- .../react/uimanager/BaseViewManager.java | 3 +- .../uimanager/ReactAccessibilityDelegate.java | 269 ++++++++++++++++-- .../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 +- .../main/res/views/uimanager/values/ids.xml | 3 + .../main/third-party/android/androidx/BUCK | 20 +- .../AccessibilityAndroidExample.android.js | 82 ++++++ 16 files changed, 422 insertions(+), 49 deletions(-) diff --git a/Libraries/Text/Text.js b/Libraries/Text/Text.js index 97ce10d0a2099d..e856ffb733c401 100644 --- a/Libraries/Text/Text.js +++ b/Libraries/Text/Text.js @@ -120,6 +120,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 858aaba750f19b..4a564c14fead02 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) => void, }>; export const NativeText: HostComponent = (createReactNativeComponentClass( diff --git a/ReactAndroid/build.gradle b/ReactAndroid/build.gradle index 6d5dd73ee27c38..78127da0fec2aa 100644 --- a/ReactAndroid/build.gradle +++ b/ReactAndroid/build.gradle @@ -499,7 +499,8 @@ dependencies { api("com.facebook.infer.annotation:infer-annotation:0.18.0") api("com.facebook.yoga:proguard-annotations:1.19.0") api("javax.inject:javax.inject:1") - api("androidx.appcompat:appcompat:1.0.2") + api("androidx.appcompat:appcompat:1.3.0") + api("androidx.appcompat:appcompat-resources:1.3.0") api("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0") api("com.facebook.fresco:fresco:${FRESCO_VERSION}") api("com.facebook.fresco:imagepipeline-okhttp3:${FRESCO_VERSION}") 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 25579c4c292f6b..0e902dd5aa244d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -418,7 +418,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 diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java index 7085883a73081c..e33c1a83306bbd 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java @@ -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; @@ -35,13 +42,15 @@ 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 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"; @@ -58,6 +67,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; /** @@ -178,8 +190,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(); mHandler = new Handler() { @@ -189,6 +203,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); } @Override @@ -376,18 +398,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)) { @@ -433,7 +443,8 @@ 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. @@ -441,8 +452,224 @@ public static void setDelegate(final View 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 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/text/BUCK b/ReactAndroid/src/main/java/com/facebook/react/views/text/BUCK index cf4fbdd897016a..d16509a4c07f09 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/BUCK @@ -29,5 +29,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 f685bf567b2455..593d03512b74a9 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.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 @@ private static void buildSpannedFromShadowNode( 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 @@ protected Spannable spannedFromShadowNode( 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 void setBackgroundColor(@Nullable Integer color) { } } + @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 bb093d2691ddca..b2639d78268c3b 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 @@ 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 @@ public void onClick(@NonNull View view) { @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 7ce861de7bfdff..15ce8c3c6c7672 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.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; @@ -543,4 +547,20 @@ public Spannable getSpanned() { 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 f4f618c5ef95e3..3a6585321d45f7 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 @@ 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.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; @@ -56,11 +58,24 @@ public ReactTextView createViewInstance(ThemedReactContext context) { @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 13311fef41bb2d..022c426eef7a8c 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; @@ -545,9 +546,11 @@ private void setTextTransform(@Nullable String textTransform) { 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 bbb57f87e88c0d..4c8981b5974484 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 @@ -32,7 +32,6 @@ import com.facebook.react.bridge.WritableArray; import com.facebook.react.common.build.ReactBuildConfig; 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; @@ -124,12 +123,10 @@ private static void buildSpannableFromFragment( 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 422d986a636170..4de87636b0af20 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.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; @@ -138,12 +137,10 @@ private static void buildSpannableFromFragment( 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/res/views/uimanager/values/ids.xml b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 6886defd469257..99b0e0ecf73089 100644 --- a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -27,4 +27,7 @@ + + + diff --git a/ReactAndroid/src/main/third-party/android/androidx/BUCK b/ReactAndroid/src/main/third-party/android/androidx/BUCK index 2eec208a3e473f..10df9c2db89728 100644 --- a/ReactAndroid/src/main/third-party/android/androidx/BUCK +++ b/ReactAndroid/src/main/third-party/android/androidx/BUCK @@ -12,6 +12,7 @@ fb_native.android_library( exported_deps = [ ":annotation", ":appcompat-binary", + ":appcompat-resources-binary", ":collection", ":core", ":cursoradapter", @@ -337,6 +338,11 @@ fb_native.android_prebuilt_aar( aar = ":appcompat-binary-aar", ) +fb_native.android_prebuilt_aar( + name = "appcompat-resources-binary", + aar = ":appcompat-resources-binary-aar", +) + fb_native.android_prebuilt_aar( name = "asynclayoutinflater-binary", aar = ":asynclayoutinflater-binary-aar", @@ -491,8 +497,14 @@ fb_native.remote_file( fb_native.remote_file( name = "appcompat-binary-aar", - sha1 = "002533a36c928bb27a3cc6843a25f83754b3c3ae", - url = "mvn:androidx.appcompat:appcompat:aar:1.0.2", + sha1 = "beebea39b2ef5604ee50838ab1b456de9d133140", + url = "mvn:androidx.appcompat:appcompat:aar:1.3.0", +) + +fb_native.remote_file( + name = "appcompat-resources-binary-aar", + sha1 = "fd7a8a9933b70c5e1da5243e35682058bc355eb1", + url = "mvn:androidx.appcompat:appcompat-resources:aar:1.3.0", ) fb_native.remote_file( @@ -509,8 +521,8 @@ fb_native.remote_file( fb_native.remote_file( name = "core-binary-aar", - sha1 = "263deba7f9c24bd0cefb93c0aaaf402cc50828ee", - url = "mvn:androidx.core:core:aar:1.0.1", + sha1 = "592da26c6f1263a51134546204033e0964e3c540", + url = "mvn:androidx.core:core:aar:1.5.0", ) fb_native.remote_file( diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js index 3f807fdc5982b1..88f33df2c1943a 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js @@ -143,6 +143,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('pressed test'); + }}> + test + {' '} + of{' '} + { + alert('pressed Inline Links'); + }}> + inline links + {' '} + in React Native. Here's{' '} + { + alert('pressed another link'); + }}> + another link + + . Here is a{' '} + { + alert('pressed long link'); + }}> + link that spans multiple lines because the text is so long. + + This sentence has no links in it. + + ); } @@ -167,6 +238,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'; From f5d053cb5f5327a6143ee8e5b98a45384fa4eb2e Mon Sep 17 00:00:00 2001 From: fabriziobertoglio1987 Date: Fri, 18 Feb 2022 12:21:39 +0800 Subject: [PATCH 2/5] Remove .gitattributes causing error add_cacheinfo More info at https://github.com/fabriziobertoglio1987/react-native-notes/issues/8 https://github.com/facebook/react-native/pull/31128#issuecomment-825986802 >If you want to see how these files are actually stored: >Remove .gitattributes (so that no conversion happens) I solved the problem with the following steps: 1) checkout branch I want to rebase 2) the branch was already rebased and included commit https://github.com/facebook/react-native/commit/73844712b6283dcc41805129cc8da305d5490c0b which brought `.gitattributes` file in the branch The file below triggers the error `error: add_cacheinfo failed to refresh for path 'packages/react-native-gradle-plugin/gradlew.bat'; merge aborting.` when running `git merge master`. ``` -# Windows files should use crlf line endings -# https://help.github.com/articles/dealing-with-line-endings/ -*.bat text eol=crlf ``` 3) I remove the `.gitattributes` files from the branch. 4) `git` stops converting the line ending 5) I merge main branch. `.gitattributes` files was already removed from main branch with https://github.com/facebook/react-native/pull/31398/files. Git runs the merge without problems. Also no diff because the file already deleted from main. --- .gitattributes | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 45a3dcb2a20316..00000000000000 --- a/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -# Windows files should use crlf line endings -# https://help.github.com/articles/dealing-with-line-endings/ -*.bat text eol=crlf From c56ee8b5210b7e6c7d340943417dab3467ed4f2b Mon Sep 17 00:00:00 2001 From: fabriziobertoglio1987 Date: Thu, 3 Mar 2022 14:59:48 +0800 Subject: [PATCH 3/5] update EditText and Slider AccessDelegates --- .../react/views/slider/ReactSliderManager.java | 9 +++++++-- .../react/views/textinput/ReactEditText.java | 13 +++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) 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 78203bbaf20215..86cbe70bcc3308 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.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 getShadowNodeClass() { @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 @@ protected ViewManagerDelegate getDelegate() { } 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/textinput/ReactEditText.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index 17ef123d0d20de..6ce001a1789019 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,13 @@ public ReactEditText(Context context) { setLayerType(View.LAYER_TYPE_SOFTWARE, null); } - ViewCompat.setAccessibilityDelegate( - this, - new ReactAccessibilityDelegate() { + /* + ReactAccessibilityDelegate.setDelegate( + this, this.isFocusable(), this.getImportantForAccessibility()); + */ + 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 +176,8 @@ public boolean performAccessibilityAction(View host, int action, Bundle args) { } return super.performAccessibilityAction(host, action, args); } - }); + }; + ViewCompat.setAccessibilityDelegate(this, editTextAccessibilityDelegate); } @Override From af0eecf19cd572b2383aa3d5db26dda8bd23952a Mon Sep 17 00:00:00 2001 From: fabriziobertoglio1987 Date: Thu, 3 Mar 2022 15:42:22 +0800 Subject: [PATCH 4/5] remove comment --- .../com/facebook/react/views/textinput/ReactEditText.java | 4 ---- 1 file changed, 4 deletions(-) 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 6ce001a1789019..00db58afb5a3b7 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,10 +154,6 @@ public ReactEditText(Context context) { setLayerType(View.LAYER_TYPE_SOFTWARE, null); } - /* - ReactAccessibilityDelegate.setDelegate( - this, this.isFocusable(), this.getImportantForAccessibility()); - */ ReactAccessibilityDelegate editTextAccessibilityDelegate = new ReactAccessibilityDelegate( this, this.isFocusable(), this.getImportantForAccessibility()) { From 561266fc180b96d6337d6c6c5c3323522d66cc44 Mon Sep 17 00:00:00 2001 From: fabriziobertoglio1987 Date: Thu, 3 Mar 2022 15:48:52 +0800 Subject: [PATCH 5/5] analysis-bot no-alert: Unexpected alert. https://github.com/facebook/react-native/pull/31757/files#r655695325 --- .../AccessibilityAndroidExample.android.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js index 014bbdc21dfb82..35e1be366ca084 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', @@ -180,7 +186,7 @@ class AccessibilityAndroidExample extends React.Component< style={styles.link} accessibilityRole="link" onPress={() => { - alert('pressed test'); + Alert.alert('pressed test'); }}> test {' '} @@ -189,7 +195,7 @@ class AccessibilityAndroidExample extends React.Component< style={styles.link} accessibilityRole="link" onPress={() => { - alert('pressed Inline Links'); + Alert.alert('pressed Inline Links'); }}> inline links {' '} @@ -198,7 +204,7 @@ class AccessibilityAndroidExample extends React.Component< style={styles.link} accessibilityRole="link" onPress={() => { - alert('pressed another link'); + Alert.alert('pressed another link'); }}> another link @@ -207,7 +213,7 @@ class AccessibilityAndroidExample extends React.Component< style={styles.link} accessibilityRole="link" onPress={() => { - alert('pressed long link'); + Alert.alert('pressed long link'); }}> link that spans multiple lines because the text is so long.