mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
Move text-specific a11y logic in ReactAccessibilityDelegate to subclass (#49377)
Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/49377 ReactAccessibilityDelegate exists to handle much of the accessibility tasks in the various Views in RN. There is quite a bit of text specific logic, mostly related to virtual views and nested links within a TextView. I decided to subclass this into a TextView-specific version because I need this delegate to reference TextView or ReactClickableSpan, which live under `react/views` while ReactAccessibilityDelegate live under `react/uimanager`. The former depends on the latter, so making the latter depend on the former would for a dependency cycle that would break builds. I thought about making a separate package for this but both `react/views` and `react/uimanager` need to include ReactAccessibilityDelegate so we would still have a cycle. mAccessibilityLinks is only set on ReactTextViewManager, so this is purely a text thing. Subclassing is not the most ideal as it extends the inheritance chain some more but I do not see a better option. Changelog: [Internal] Reviewed By: mdvacca Differential Revision: D69499115 fbshipit-source-id: 1720d20bb56ba1e1b5bd114d32bc70e80e3b4558
This commit is contained in:
committed by
Facebook GitHub Bot
parent
50878c248d
commit
d65d846ccf
@@ -3822,6 +3822,7 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo
|
||||
public fun setTranslateY (Landroid/view/View;F)V
|
||||
public fun setViewState (Landroid/view/View;Lcom/facebook/react/bridge/ReadableMap;)V
|
||||
public fun setZIndex (Landroid/view/View;F)V
|
||||
protected fun updateViewAccessibility (Landroid/view/View;)V
|
||||
}
|
||||
|
||||
public abstract class com/facebook/react/uimanager/BaseViewManagerDelegate : com/facebook/react/uimanager/ViewManagerDelegate {
|
||||
@@ -4106,7 +4107,7 @@ public class com/facebook/react/uimanager/ReactAccessibilityDelegate : androidx/
|
||||
public fun <init> (Landroid/view/View;ZI)V
|
||||
public static fun createNodeInfoFromView (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;
|
||||
public fun getAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat;
|
||||
protected fun getFirstSpan (IILjava/lang/Class;)Ljava/lang/Object;
|
||||
protected fun getHostView ()Landroid/view/View;
|
||||
public static fun getTalkbackDescription (Landroid/view/View;Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;)Ljava/lang/CharSequence;
|
||||
protected fun getVirtualViewAt (FF)I
|
||||
protected fun getVisibleVirtualViews (Ljava/util/List;)V
|
||||
@@ -4124,13 +4125,7 @@ public class com/facebook/react/uimanager/ReactAccessibilityDelegate : androidx/
|
||||
public static fun resetDelegate (Landroid/view/View;ZI)V
|
||||
public static fun setDelegate (Landroid/view/View;ZI)V
|
||||
public static fun setRole (Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat;Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityRole;Landroid/content/Context;)V
|
||||
}
|
||||
|
||||
public class com/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks {
|
||||
public fun <init> ([Landroid/text/style/ClickableSpan;Landroid/text/Spannable;)V
|
||||
public fun getLinkById (I)Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
|
||||
public fun getLinkBySpanPos (II)Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityLinks$AccessibleLink;
|
||||
public fun size ()I
|
||||
public fun superGetAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat;
|
||||
}
|
||||
|
||||
public final class com/facebook/react/uimanager/ReactAccessibilityDelegate$AccessibilityRole : java/lang/Enum {
|
||||
@@ -6922,6 +6917,8 @@ public class com/facebook/react/views/text/ReactTextViewManager : com/facebook/r
|
||||
public fun updateExtraData (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/Object;)V
|
||||
public synthetic fun updateState (Landroid/view/View;Lcom/facebook/react/uimanager/ReactStylesDiffMap;Lcom/facebook/react/uimanager/StateWrapper;)Ljava/lang/Object;
|
||||
public fun updateState (Lcom/facebook/react/views/text/ReactTextView;Lcom/facebook/react/uimanager/ReactStylesDiffMap;Lcom/facebook/react/uimanager/StateWrapper;)Ljava/lang/Object;
|
||||
protected synthetic fun updateViewAccessibility (Landroid/view/View;)V
|
||||
protected fun updateViewAccessibility (Lcom/facebook/react/views/text/ReactTextView;)V
|
||||
}
|
||||
|
||||
public abstract interface class com/facebook/react/views/text/ReactTextViewManagerCallback {
|
||||
|
||||
+1
-1
@@ -626,7 +626,7 @@ public abstract class BaseViewManager<T extends View, C extends LayoutShadowNode
|
||||
throw new IllegalStateException("Invalid float property value: " + value);
|
||||
}
|
||||
|
||||
private void updateViewAccessibility(@NonNull T view) {
|
||||
protected void updateViewAccessibility(@NonNull T view) {
|
||||
ReactAccessibilityDelegate.setDelegate(
|
||||
view, view.isFocusable(), view.getImportantForAccessibility());
|
||||
}
|
||||
|
||||
+15
-227
@@ -8,22 +8,15 @@
|
||||
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.Layout;
|
||||
import android.text.Spannable;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.AbsoluteSizeSpan;
|
||||
import android.text.style.ClickableSpan;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.view.ViewCompat;
|
||||
@@ -44,12 +37,10 @@ import com.facebook.react.bridge.ReadableMapKeySetIterator;
|
||||
import com.facebook.react.bridge.ReadableType;
|
||||
import com.facebook.react.bridge.UIManager;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole;
|
||||
import com.facebook.react.uimanager.common.ViewUtil;
|
||||
import com.facebook.react.uimanager.events.Event;
|
||||
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||
import com.facebook.react.uimanager.util.ReactFindViewUtil;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
@@ -79,7 +70,6 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
|
||||
}
|
||||
|
||||
private final View mView;
|
||||
private final AccessibilityLinks mAccessibilityLinks;
|
||||
|
||||
private Handler mHandler;
|
||||
|
||||
@@ -405,7 +395,11 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
|
||||
// announcement coalescing.
|
||||
mView.setFocusable(originalFocus);
|
||||
ViewCompat.setImportantForAccessibility(mView, originalImportantForAccessibility);
|
||||
mAccessibilityLinks = (AccessibilityLinks) mView.getTag(R.id.accessibility_links);
|
||||
}
|
||||
|
||||
// The View this delegate is attached to
|
||||
protected View getHostView() {
|
||||
return mView;
|
||||
}
|
||||
|
||||
@Nullable View mAccessibilityLabelledBy;
|
||||
@@ -716,143 +710,17 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
|
||||
|
||||
@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;
|
||||
return INVALID_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
|
||||
if (mAccessibilityLinks == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < mAccessibilityLinks.size(); i++) {
|
||||
virtualViewIds.add(i);
|
||||
}
|
||||
}
|
||||
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
// NOTE: The span may not actually have visible bounds within its parent,
|
||||
// due to line limits, etc.
|
||||
final Rect bounds = getBoundsInParent(accessibleTextSpan);
|
||||
if (bounds == null) {
|
||||
node.setContentDescription("");
|
||||
node.setBoundsInParent(new Rect(0, 0, 1, 1));
|
||||
return;
|
||||
}
|
||||
|
||||
node.setContentDescription(accessibleTextSpan.description);
|
||||
node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
|
||||
node.setBoundsInParent(bounds);
|
||||
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());
|
||||
}
|
||||
|
||||
double startOffset = accessibleLink.start;
|
||||
double endOffset = accessibleLink.end;
|
||||
|
||||
// Ensure the link hasn't been ellipsized away; in such cases,
|
||||
// getPrimaryHorizontal will crash (and the link isn't rendered anyway).
|
||||
int startOffsetLineNumber = textViewLayout.getLineForOffset((int) startOffset);
|
||||
int lineEndOffset = textViewLayout.getLineEnd(startOffsetLineNumber);
|
||||
if (startOffset > lineEndOffset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Rect rootRect = new Rect();
|
||||
|
||||
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 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);
|
||||
node.setContentDescription("");
|
||||
node.setBoundsInParent(new Rect(0, 0, 1, 1));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -861,97 +729,17 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected @Nullable <T> T getFirstSpan(int start, int end, Class<T> 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<AccessibleLink> mLinks;
|
||||
|
||||
public AccessibilityLinks(ClickableSpan[] spans, Spannable text) {
|
||||
ArrayList<AccessibleLink> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) {
|
||||
// Only set a NodeProvider if we have virtual views, otherwise just return null here so that
|
||||
// we fall back to the View class's default behavior. If we don't do this, then Views with
|
||||
// no virtual children will fall back to using ExploreByTouchHelper's onPopulateNodeForHost
|
||||
// method to populate their AccessibilityNodeInfo, which defaults to doing nothing, so no
|
||||
// AccessibilityNodeInfo will be created. Alternatively, we could override
|
||||
// onPopulateNodeForHost instead, and have it create an AccessibilityNodeInfo for the host
|
||||
// but this is what the default View class does by itself, so we may as well defer to it.
|
||||
if (mAccessibilityLinks != null) {
|
||||
return super.getAccessibilityNodeProvider(host);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// This exists so classes that extend this can properly call super's impl of this method while
|
||||
// still being able to override it properly for this class
|
||||
public @Nullable AccessibilityNodeProviderCompat superGetAccessibilityNodeProvider(View host) {
|
||||
return super.getAccessibilityNodeProvider(host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the supplied {@link View} and {@link AccessibilityNodeInfoCompat} has any
|
||||
* children which are not independently accessibility focusable and also have a spoken
|
||||
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* Copyright (c) Meta Platforms, Inc. and 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.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.text.Spannable
|
||||
import android.text.Spanned
|
||||
import android.text.style.AbsoluteSizeSpan
|
||||
import android.text.style.ClickableSpan
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
|
||||
import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
|
||||
import com.facebook.react.R
|
||||
import com.facebook.react.uimanager.ReactAccessibilityDelegate
|
||||
import kotlin.math.ceil
|
||||
|
||||
internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
|
||||
public constructor(
|
||||
view: View,
|
||||
originalFocus: Boolean,
|
||||
originalImportantForAccessibility: Int
|
||||
) : super(view, originalFocus, originalImportantForAccessibility) {
|
||||
accessibilityLinks = hostView.getTag(R.id.accessibility_links) as AccessibilityLinks?
|
||||
}
|
||||
|
||||
private var accessibilityLinks: AccessibilityLinks? = null
|
||||
|
||||
public companion object {
|
||||
public fun setDelegate(
|
||||
view: View,
|
||||
originalFocus: Boolean,
|
||||
originalImportantForAccessibility: Int
|
||||
) {
|
||||
// if a view already has an accessibility delegate, replacing it could cause
|
||||
// problems,so leave it alone.
|
||||
if (!ViewCompat.hasAccessibilityDelegate(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 ||
|
||||
view.getTag(R.id.accessibility_collection_item) != null ||
|
||||
view.getTag(R.id.accessibility_links) != null ||
|
||||
view.getTag(R.id.role) != null)) {
|
||||
ViewCompat.setAccessibilityDelegate(
|
||||
view,
|
||||
ReactTextViewAccessibilityDelegate(
|
||||
view, originalFocus, originalImportantForAccessibility))
|
||||
}
|
||||
}
|
||||
|
||||
public fun resetDelegate(
|
||||
view: View,
|
||||
originalFocus: Boolean,
|
||||
originalImportantForAccessibility: Int
|
||||
) {
|
||||
ViewCompat.setAccessibilityDelegate(
|
||||
view,
|
||||
ReactTextViewAccessibilityDelegate(
|
||||
view, originalFocus, originalImportantForAccessibility))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int?>) {
|
||||
val accessibilityLinks = accessibilityLinks ?: return
|
||||
|
||||
for (i in 0 until accessibilityLinks.size()) {
|
||||
virtualViewIds.add(i)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getVirtualViewAt(x: Float, y: Float): Int {
|
||||
val accessibilityLinks = accessibilityLinks ?: return INVALID_ID
|
||||
if (accessibilityLinks.size() == 0 || hostView !is TextView) {
|
||||
return INVALID_ID
|
||||
}
|
||||
|
||||
var x = x
|
||||
var y = y
|
||||
|
||||
val textView = hostView as TextView
|
||||
if (textView.text !is Spanned) {
|
||||
return INVALID_ID
|
||||
}
|
||||
|
||||
val layout = textView.layout ?: return INVALID_ID
|
||||
|
||||
x -= textView.totalPaddingLeft.toFloat()
|
||||
y -= textView.totalPaddingTop.toFloat()
|
||||
x += textView.scrollX.toFloat()
|
||||
y += textView.scrollY.toFloat()
|
||||
|
||||
val line = layout.getLineForVertical(y.toInt())
|
||||
val charOffset = layout.getOffsetForHorizontal(line, x)
|
||||
|
||||
val clickableSpan =
|
||||
getFirstSpan(charOffset, charOffset, ClickableSpan::class.java) ?: return INVALID_ID
|
||||
|
||||
val spanned = textView.text as Spanned
|
||||
val start = spanned.getSpanStart(clickableSpan)
|
||||
val end = spanned.getSpanEnd(clickableSpan)
|
||||
|
||||
val link: AccessibilityLinks.AccessibleLink? = accessibilityLinks.getLinkBySpanPos(start, end)
|
||||
return link?.id ?: INVALID_ID
|
||||
}
|
||||
|
||||
protected fun <T> getFirstSpan(start: Int, end: Int, classType: Class<T>?): T? {
|
||||
if (hostView !is TextView || (hostView as TextView).text !is Spanned) {
|
||||
return null
|
||||
}
|
||||
|
||||
val spanned = (hostView as TextView).text as Spanned
|
||||
val spans = spanned.getSpans(start, end, classType)
|
||||
return if (spans.isNotEmpty()) spans[0] else null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfoCompat) {
|
||||
// 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.
|
||||
val accessibilityLinks = accessibilityLinks
|
||||
if (accessibilityLinks == null) {
|
||||
node.contentDescription = ""
|
||||
node.setBoundsInParent(Rect(0, 0, 1, 1))
|
||||
return
|
||||
}
|
||||
|
||||
val accessibleTextSpan: AccessibilityLinks.AccessibleLink? =
|
||||
accessibilityLinks.getLinkById(virtualViewId)
|
||||
if (accessibleTextSpan == null) {
|
||||
node.contentDescription = ""
|
||||
node.setBoundsInParent(Rect(0, 0, 1, 1))
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: The span may not actually have visible bounds within its parent,
|
||||
// due to line limits, etc.
|
||||
val bounds = getBoundsInParent(accessibleTextSpan)
|
||||
if (bounds == null) {
|
||||
node.contentDescription = ""
|
||||
node.setBoundsInParent(Rect(0, 0, 1, 1))
|
||||
return
|
||||
}
|
||||
|
||||
node.contentDescription = accessibleTextSpan.description
|
||||
node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK)
|
||||
node.setBoundsInParent(bounds)
|
||||
node.roleDescription = hostView.resources.getString(R.string.link_description)
|
||||
node.className = AccessibilityRole.getValue(AccessibilityRole.BUTTON)
|
||||
}
|
||||
|
||||
private fun getBoundsInParent(accessibleLink: AccessibilityLinks.AccessibleLink): Rect? {
|
||||
// This view is not a text view, so return the entire views bounds.
|
||||
if (hostView !is TextView) {
|
||||
return Rect(0, 0, hostView.width, hostView.height)
|
||||
}
|
||||
|
||||
val textView = hostView as TextView
|
||||
val textViewLayout = textView.layout ?: return Rect(0, 0, textView.width, textView.height)
|
||||
|
||||
val startOffset = accessibleLink.start
|
||||
val endOffset = accessibleLink.end
|
||||
|
||||
// Ensure the link hasn't been ellipsized away; in such cases,
|
||||
// getPrimaryHorizontal will crash (and the link isn't rendered anyway).
|
||||
val startOffsetLineNumber = textViewLayout.getLineForOffset(startOffset)
|
||||
val lineEndOffset = textViewLayout.getLineEnd(startOffsetLineNumber)
|
||||
if (startOffset > lineEndOffset) {
|
||||
return null
|
||||
}
|
||||
|
||||
val rootRect = Rect()
|
||||
|
||||
val startXCoordinates = textViewLayout.getPrimaryHorizontal(startOffset).toDouble()
|
||||
|
||||
val paint = Paint()
|
||||
val sizeSpan =
|
||||
getFirstSpan(accessibleLink.start, accessibleLink.end, AbsoluteSizeSpan::class.java)
|
||||
val textSize = sizeSpan?.size?.toFloat() ?: textView.textSize
|
||||
paint.textSize = textSize
|
||||
val textWidth = ceil(paint.measureText(accessibleLink.description).toDouble()).toInt()
|
||||
|
||||
val endOffsetLineNumber = textViewLayout.getLineForOffset(endOffset)
|
||||
val isMultiline = startOffsetLineNumber != endOffsetLineNumber
|
||||
textViewLayout.getLineBounds(startOffsetLineNumber, rootRect)
|
||||
|
||||
val verticalOffset = textView.scrollY + textView.totalPaddingTop
|
||||
rootRect.top += verticalOffset
|
||||
rootRect.bottom += verticalOffset
|
||||
rootRect.left =
|
||||
(rootRect.left + (startXCoordinates + textView.totalPaddingLeft - textView.scrollX)).toInt()
|
||||
|
||||
// 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 Rect(rootRect.left, rootRect.top, rootRect.right, rootRect.bottom)
|
||||
}
|
||||
|
||||
return Rect(rootRect.left, rootRect.top, rootRect.left + textWidth, rootRect.bottom)
|
||||
}
|
||||
|
||||
override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProviderCompat? {
|
||||
// Only set a NodeProvider if we have virtual views, otherwise just return null here so that
|
||||
// we fall back to the View class's default behavior. If we don't do this, then Views with
|
||||
// no virtual children will fall back to using ExploreByTouchHelper's onPopulateNodeForHost
|
||||
// method to populate their AccessibilityNodeInfo, which defaults to doing nothing, so no
|
||||
// AccessibilityNodeInfo will be created. Alternatively, we could override
|
||||
// onPopulateNodeForHost instead, and have it create an AccessibilityNodeInfo for the host
|
||||
// but this is what the default View class does by itself, so we may as well defer to it.
|
||||
if (accessibilityLinks != null) {
|
||||
return superGetAccessibilityNodeProvider(host)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public class AccessibilityLinks(spans: Array<ClickableSpan?>, text: Spannable) {
|
||||
private val links: List<AccessibleLink>
|
||||
|
||||
init {
|
||||
val accessibleLinks = mutableListOf<AccessibleLink>()
|
||||
for (i in spans.indices) {
|
||||
val span = spans[i]
|
||||
val start = text.getSpanStart(span)
|
||||
val 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
|
||||
}
|
||||
|
||||
val link = 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.size - 1 - i
|
||||
accessibleLinks.add(link)
|
||||
}
|
||||
links = accessibleLinks
|
||||
}
|
||||
|
||||
public fun getLinkById(id: Int): AccessibleLink? {
|
||||
for (link in links) {
|
||||
if (link.id == id) {
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public fun getLinkBySpanPos(start: Int, end: Int): AccessibleLink? {
|
||||
for (link in links) {
|
||||
if (link.start == start && link.end == end) {
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public fun size(): Int {
|
||||
return links.size
|
||||
}
|
||||
|
||||
public class AccessibleLink {
|
||||
public var description: String? = null
|
||||
public var start: Int = 0
|
||||
public var end: Int = 0
|
||||
public var id: Int = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -21,7 +21,6 @@ import com.facebook.react.internal.SystraceSection;
|
||||
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags;
|
||||
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;
|
||||
@@ -83,6 +82,12 @@ public class ReactTextViewManager
|
||||
return REACT_CLASS;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateViewAccessibility(@NonNull ReactTextView view) {
|
||||
ReactTextViewAccessibilityDelegate.Companion.setDelegate(
|
||||
view, view.isFocusable(), view.getImportantForAccessibility());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReactTextView createViewInstance(ThemedReactContext context) {
|
||||
return new ReactTextView(context);
|
||||
@@ -106,8 +111,8 @@ public class ReactTextViewManager
|
||||
if (clickableSpans.length > 0) {
|
||||
view.setTag(
|
||||
R.id.accessibility_links,
|
||||
new ReactAccessibilityDelegate.AccessibilityLinks(clickableSpans, spannable));
|
||||
ReactAccessibilityDelegate.resetDelegate(
|
||||
new ReactTextViewAccessibilityDelegate.AccessibilityLinks(clickableSpans, spannable));
|
||||
ReactTextViewAccessibilityDelegate.Companion.resetDelegate(
|
||||
view, view.isFocusable(), view.getImportantForAccessibility());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user