From b352e2da8137452f66717cf1cecb2e72abd727d7 Mon Sep 17 00:00:00 2001 From: Emily Janzer Date: Tue, 15 Sep 2020 13:20:11 -0700 Subject: [PATCH] Create a ClickableSpan for nested Text components Summary: Right now nested Text components are not accessible on Android. This is because we only create a native ReactTextView for the parent component; the styling and touch handling for the child component are handled using spans. In order for TalkBack to announce the link, we need to linkify the text using a ClickableSpan. This diff adds ReactClickableSpan, which TextLayoutManager uses to linkify a span of text when its corresponding React component has `accessibilityRole="link"`. For example: A paragraph with some links surrounded by other text. With this diff, the child Text component will be announced by TalkBack ('links available') and exposed as an option in the context menu. Clicking on the link in the context menu fires the Text component's onClick, which we're explicitly forwarding to onPress in Text.js (for now - ideally this would probably use a separate event, but that would involve wiring it up in the renderer as well). ReactClickableSpan also applies text color from React if it exists; this is to override the default Android link styling (teal + underline). Changelog: [Android][Fixed] Make nested Text components accessible as links Reviewed By: yungsters, mdvacca Differential Revision: D23553222 fbshipit-source-id: a962b2833d73ec81047e86cfb41846513c486d87 --- Libraries/Text/Text.js | 8 ++- .../react/views/text/ReactClickableSpan.java | 69 +++++++++++++++++++ .../react/views/text/TextAttributeProps.java | 13 ++++ .../react/views/text/TextLayoutManager.java | 8 ++- .../renderer/attributedstring/conversions.h | 4 ++ 5 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/text/ReactClickableSpan.java diff --git a/Libraries/Text/Text.js b/Libraries/Text/Text.js index cce9cb3d9547e0..7d34417875ece5 100644 --- a/Libraries/Text/Text.js +++ b/Libraries/Text/Text.js @@ -151,7 +151,13 @@ class TouchableText extends React.Component { {hasTextAncestor => hasTextAncestor ? ( - + ) : ( 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 new file mode 100644 index 00000000000000..37f7000a915b9c --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactClickableSpan.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text; + +import android.text.TextPaint; +import android.text.style.ClickableSpan; +import android.view.View; +import androidx.annotation.NonNull; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.uimanager.UIManagerHelper; +import com.facebook.react.uimanager.events.EventDispatcher; +import com.facebook.react.views.view.ViewGroupClickEvent; + +/** + * This class is used in {@link TextLayoutManager} to linkify and style a span of text with + * accessibilityRole="link". This is needed to make nested Text components accessible. + * + *

For example, if your React component looks like this: + * + *

{@code
+ * 
+ *   Some text with
+ *   a link
+ *   in the middle.
+ * 
+ * }
+ * + * then only one {@link ReactTextView} will be created, for the parent. The child Text component + * does not exist as a native view, and therefore has no accessibility properties. Instead, we have + * to use spans on the parent's {@link ReactTextView} to properly style the child, and to make it + * accessible (TalkBack announces that the text has links available, and the links are exposed in + * the context menu). + */ +class ReactClickableSpan extends ClickableSpan implements ReactSpan { + + private final int mReactTag; + private final int mForegroundColor; + + ReactClickableSpan(int reactTag, int foregroundColor) { + mReactTag = reactTag; + mForegroundColor = foregroundColor; + } + + @Override + public void onClick(@NonNull View view) { + ReactContext context = (ReactContext) view.getContext(); + EventDispatcher eventDispatcher = + UIManagerHelper.getEventDispatcherForReactTag(context, mReactTag); + if (eventDispatcher != null) { + eventDispatcher.dispatchEvent(new ViewGroupClickEvent(mReactTag)); + } + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + super.updateDrawState(ds); + ds.setColor(mForegroundColor); + ds.setUnderlineText(false); + } + + public int getReactTag() { + return mReactTag; + } +} 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 29e3e2969fb879..f99e24019e5236 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 @@ -17,6 +17,7 @@ import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; 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.YogaDirection; @@ -72,6 +73,9 @@ public class TextAttributeProps { protected boolean mIsLineThroughTextDecorationSet = false; protected boolean mIncludeFontPadding = true; + protected @Nullable ReactAccessibilityDelegate.AccessibilityRole mAccessibilityRole = null; + protected boolean mIsAccessibilityRoleSet = false; + /** * mFontStyle can be {@link Typeface#NORMAL} or {@link Typeface#ITALIC}. mFontWeight can be {@link * Typeface#NORMAL} or {@link Typeface#BOLD}. @@ -134,6 +138,7 @@ public TextAttributeProps(ReactStylesDiffMap props) { setTextShadowColor(getIntProp(PROP_SHADOW_COLOR, DEFAULT_TEXT_SHADOW_COLOR)); setTextTransform(getStringProp(PROP_TEXT_TRANSFORM)); setLayoutDirection(getStringProp(ViewProps.LAYOUT_DIRECTION)); + setAccessibilityRole(getStringProp(ViewProps.ACCESSIBILITY_ROLE)); } public static int getTextAlignment(ReactStylesDiffMap props, boolean isRTL) { @@ -412,6 +417,14 @@ public void setTextTransform(@Nullable String textTransform) { } } + public void setAccessibilityRole(@Nullable String accessibilityRole) { + if (accessibilityRole != null) { + mIsAccessibilityRoleSet = accessibilityRole != null; + mAccessibilityRole = + ReactAccessibilityDelegate.AccessibilityRole.fromValue(accessibilityRole); + } + } + public static int getTextBreakStrategy(@Nullable String textBreakStrategy) { int androidTextBreakStrategy = DEFAULT_BREAK_STRATEGY; if (textBreakStrategy != null) { 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 9c524d9f078059..c1143b430830e7 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 @@ -29,6 +29,7 @@ import com.facebook.react.bridge.ReadableNativeMap; 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; @@ -115,7 +116,12 @@ private static void buildSpannableFromFragment( sb.length(), new TextInlineViewPlaceholderSpan(reactTag, (int) width, (int) height))); } else if (end >= start) { - if (textAttributes.mIsColorSet) { + if (ReactAccessibilityDelegate.AccessibilityRole.LINK.equals( + textAttributes.mAccessibilityRole)) { + ops.add( + new SetSpanOperation( + start, end, new ReactClickableSpan(reactTag, textAttributes.mColor))); + } else if (textAttributes.mIsColorSet) { ops.add( new SetSpanOperation( start, end, new ReactForegroundColorSpan(textAttributes.mColor))); diff --git a/ReactCommon/react/renderer/attributedstring/conversions.h b/ReactCommon/react/renderer/attributedstring/conversions.h index 26c8b5f37f0799..a121a09028e2ea 100644 --- a/ReactCommon/react/renderer/attributedstring/conversions.h +++ b/ReactCommon/react/renderer/attributedstring/conversions.h @@ -768,6 +768,10 @@ inline folly::dynamic toDynamic(const TextAttributes &textAttributes) { _textAttributes( "layoutDirection", toString(*textAttributes.layoutDirection)); } + if (textAttributes.accessibilityRole.has_value()) { + _textAttributes( + "accessibilityRole", toString(*textAttributes.accessibilityRole)); + } return _textAttributes; }