diff --git a/Libraries/Text/Text.js b/Libraries/Text/Text.js index 35c7d1bc2bf01f..b27e53400bccbd 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 b5d5557e38eddb..2879e18e699a95 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, // 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 b48b272d9c2a26..b349347ca42dc4 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 @@ 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 79693c11522e50..ffde4dfdecfbbb 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,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"; @@ -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; /** @@ -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(); mHandler = new Handler() { @@ -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; @@ -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)) { @@ -445,7 +455,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. @@ -453,8 +464,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/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/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 cdffdb37c71e0b..cbf2967d9755ab 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 bff24563aa1aba..3b71d0b1366a50 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 77885923046c63..3ea620ade2c0c0 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 @@ -25,6 +25,9 @@ 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; @@ -562,4 +565,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 a9623479824eb6..d9232820eef673 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; @@ -57,11 +59,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 ed75ed5a5d94fb..e3f15b9ebe6fcb 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 @@ 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 ecf50f5df77990..5c21e5cea0a6aa 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; @@ -125,12 +124,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 b73c0e5fa0b0b7..81b8af845dca46 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; @@ -139,12 +138,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/textinput/ReactEditText.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index 17ef123d0d20de..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,9 +154,9 @@ public ReactEditText(Context context) { 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 boolean performAccessibilityAction(View host, int action, Bundle args) { } 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 5c6c6d354d517f..b9e9b732bb3595 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 12754e4e3ec04e..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', @@ -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';