Skip to content

Commit

Permalink
Create a ClickableSpan for nested Text components
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Emily Janzer authored and facebook-github-bot committed Sep 16, 2020
1 parent b705eaf commit b352e2d
Show file tree
Hide file tree
Showing 5 changed files with 100 additions and 2 deletions.
8 changes: 7 additions & 1 deletion Libraries/Text/Text.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,13 @@ class TouchableText extends React.Component<Props, State> {
<TextAncestor.Consumer>
{hasTextAncestor =>
hasTextAncestor ? (
<RCTVirtualText {...props} ref={props.forwardedRef} />
<RCTVirtualText
{...props}
// This is used on Android to call a nested Text component's press handler from the context menu.
// TODO T75145059 Clean this up once Text is migrated off of Touchable
onClick={props.onPress}
ref={props.forwardedRef}
/>
) : (
<TextAncestor.Provider value={true}>
<RCTText {...props} ref={props.forwardedRef} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)));
Expand Down
4 changes: 4 additions & 0 deletions ReactCommon/react/renderer/attributedstring/conversions.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down

0 comments on commit b352e2d

Please sign in to comment.