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:

  <Text>
    A paragraph with some
    <Text accessible={true} accessibilityRole="link" onPress={onPress} onClick={onClick}>links</Text>
    surrounded by other text.
  </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
This commit is contained in:
Emily Janzer
2020-09-15 13:20:11 -07:00
committed by Facebook GitHub Bot
parent b705eafd22
commit b352e2da81
5 changed files with 100 additions and 2 deletions
@@ -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.
*
* <p>For example, if your React component looks like this:
*
* <pre>{@code
* <Text>
* Some text with
* <Text onPress={onPress} accessible={true} accessibilityRole="link">a link</Text>
* in the middle.
* </Text>
* }</pre>
*
* 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;
}
}
@@ -17,6 +17,7 @@ import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
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 class TextAttributeProps {
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 class TextAttributeProps {
}
}
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) {
@@ -29,6 +29,7 @@ import com.facebook.react.bridge.ReadableMap;
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 @@ public class TextLayoutManager {
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)));