8
8
package com .facebook .react .uimanager ;
9
9
10
10
import android .content .Context ;
11
+ import android .graphics .Paint ;
12
+ import android .graphics .Rect ;
11
13
import android .os .Bundle ;
12
14
import android .os .Handler ;
13
15
import android .os .Message ;
14
- import android .text .SpannableString ;
15
- import android .text .style .URLSpan ;
16
+ import android .text .Layout ;
17
+ import android .text .Spannable ;
18
+ import android .text .Spanned ;
19
+ import android .text .style .AbsoluteSizeSpan ;
20
+ import android .text .style .ClickableSpan ;
16
21
import android .view .View ;
17
22
import android .view .accessibility .AccessibilityEvent ;
23
+ import android .widget .TextView ;
24
+ import androidx .annotation .NonNull ;
18
25
import androidx .annotation .Nullable ;
19
- import androidx .core .view .AccessibilityDelegateCompat ;
20
26
import androidx .core .view .ViewCompat ;
21
27
import androidx .core .view .accessibility .AccessibilityNodeInfoCompat ;
22
28
import androidx .core .view .accessibility .AccessibilityNodeInfoCompat .AccessibilityActionCompat ;
23
29
import androidx .core .view .accessibility .AccessibilityNodeInfoCompat .RangeInfoCompat ;
30
+ import androidx .customview .widget .ExploreByTouchHelper ;
24
31
import com .facebook .react .R ;
25
32
import com .facebook .react .bridge .Arguments ;
26
33
import com .facebook .react .bridge .Dynamic ;
36
43
import com .facebook .react .uimanager .events .Event ;
37
44
import com .facebook .react .uimanager .events .EventDispatcher ;
38
45
import com .facebook .react .uimanager .util .ReactFindViewUtil ;
46
+ import java .util .ArrayList ;
39
47
import java .util .HashMap ;
48
+ import java .util .List ;
40
49
41
50
/**
42
51
* Utility class that handles the addition of a "role" for accessibility to either a View or
43
52
* AccessibilityNodeInfo.
44
53
*/
45
- public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat {
54
+ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
46
55
47
56
private static final String TAG = "ReactAccessibilityDelegate" ;
48
57
public static final String TOP_ACCESSIBILITY_ACTION_EVENT = "topAccessibilityAction" ;
@@ -59,6 +68,9 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat {
59
68
sActionIdMap .put ("decrement" , AccessibilityActionCompat .ACTION_SCROLL_BACKWARD .getId ());
60
69
}
61
70
71
+ private final View mView ;
72
+ private final AccessibilityLinks mAccessibilityLinks ;
73
+
62
74
private Handler mHandler ;
63
75
64
76
/**
@@ -179,8 +191,10 @@ public static AccessibilityRole fromValue(@Nullable String value) {
179
191
private static final String STATE_SELECTED = "selected" ;
180
192
private static final String STATE_CHECKED = "checked" ;
181
193
182
- public ReactAccessibilityDelegate () {
183
- super ();
194
+ public ReactAccessibilityDelegate (
195
+ final View view , boolean originalFocus , int originalImportantForAccessibility ) {
196
+ super (view );
197
+ mView = view ;
184
198
mAccessibilityActionsMap = new HashMap <Integer , String >();
185
199
mHandler =
186
200
new Handler () {
@@ -190,6 +204,14 @@ public void handleMessage(Message msg) {
190
204
host .sendAccessibilityEvent (AccessibilityEvent .TYPE_VIEW_SELECTED );
191
205
}
192
206
};
207
+
208
+ // We need to reset these two properties, as ExploreByTouchHelper sets focusable to "true" and
209
+ // importantForAccessibility to "Yes" (if it is Auto). If we don't reset these it would force
210
+ // every element that has this delegate attached to be focusable, and not allow for
211
+ // announcement coalescing.
212
+ mView .setFocusable (originalFocus );
213
+ ViewCompat .setImportantForAccessibility (mView , originalImportantForAccessibility );
214
+ mAccessibilityLinks = (AccessibilityLinks ) mView .getTag (R .id .accessibility_links );
193
215
}
194
216
195
217
@ Nullable View mAccessibilityLabelledBy ;
@@ -388,18 +410,6 @@ public static void setRole(
388
410
nodeInfo .setClassName (AccessibilityRole .getValue (role ));
389
411
if (role .equals (AccessibilityRole .LINK )) {
390
412
nodeInfo .setRoleDescription (context .getString (R .string .link_description ));
391
-
392
- if (nodeInfo .getContentDescription () != null ) {
393
- SpannableString spannable = new SpannableString (nodeInfo .getContentDescription ());
394
- spannable .setSpan (new URLSpan ("" ), 0 , spannable .length (), 0 );
395
- nodeInfo .setContentDescription (spannable );
396
- }
397
-
398
- if (nodeInfo .getText () != null ) {
399
- SpannableString spannable = new SpannableString (nodeInfo .getText ());
400
- spannable .setSpan (new URLSpan ("" ), 0 , spannable .length (), 0 );
401
- nodeInfo .setText (spannable );
402
- }
403
413
} else if (role .equals (AccessibilityRole .IMAGE )) {
404
414
nodeInfo .setRoleDescription (context .getString (R .string .image_description ));
405
415
} else if (role .equals (AccessibilityRole .IMAGEBUTTON )) {
@@ -445,16 +455,233 @@ public static void setRole(
445
455
}
446
456
}
447
457
448
- public static void setDelegate (final View view ) {
458
+ public static void setDelegate (
459
+ final View view , boolean originalFocus , int originalImportantForAccessibility ) {
449
460
// if a view already has an accessibility delegate, replacing it could cause
450
461
// problems,
451
462
// so leave it alone.
452
463
if (!ViewCompat .hasAccessibilityDelegate (view )
453
464
&& (view .getTag (R .id .accessibility_role ) != null
454
465
|| view .getTag (R .id .accessibility_state ) != null
455
466
|| view .getTag (R .id .accessibility_actions ) != null
456
- || view .getTag (R .id .react_test_id ) != null )) {
457
- ViewCompat .setAccessibilityDelegate (view , new ReactAccessibilityDelegate ());
467
+ || view .getTag (R .id .react_test_id ) != null
468
+ || view .getTag (R .id .accessibility_links ) != null )) {
469
+ ViewCompat .setAccessibilityDelegate (
470
+ view ,
471
+ new ReactAccessibilityDelegate (view , originalFocus , originalImportantForAccessibility ));
472
+ }
473
+ }
474
+
475
+ // Explicitly re-set the delegate, even if one has already been set.
476
+ public static void resetDelegate (
477
+ final View view , boolean originalFocus , int originalImportantForAccessibility ) {
478
+ ViewCompat .setAccessibilityDelegate (
479
+ view ,
480
+ new ReactAccessibilityDelegate (view , originalFocus , originalImportantForAccessibility ));
481
+ }
482
+
483
+ @ Override
484
+ protected int getVirtualViewAt (float x , float y ) {
485
+ if (mAccessibilityLinks == null
486
+ || mAccessibilityLinks .size () == 0
487
+ || !(mView instanceof TextView )) {
488
+ return INVALID_ID ;
489
+ }
490
+
491
+ TextView textView = (TextView ) mView ;
492
+ if (!(textView .getText () instanceof Spanned )) {
493
+ return INVALID_ID ;
494
+ }
495
+
496
+ Layout layout = textView .getLayout ();
497
+ if (layout == null ) {
498
+ return INVALID_ID ;
499
+ }
500
+
501
+ x -= textView .getTotalPaddingLeft ();
502
+ y -= textView .getTotalPaddingTop ();
503
+ x += textView .getScrollX ();
504
+ y += textView .getScrollY ();
505
+
506
+ int line = layout .getLineForVertical ((int ) y );
507
+ int charOffset = layout .getOffsetForHorizontal (line , x );
508
+
509
+ ClickableSpan clickableSpan = getFirstSpan (charOffset , charOffset , ClickableSpan .class );
510
+ if (clickableSpan == null ) {
511
+ return INVALID_ID ;
512
+ }
513
+
514
+ Spanned spanned = (Spanned ) textView .getText ();
515
+ int start = spanned .getSpanStart (clickableSpan );
516
+ int end = spanned .getSpanEnd (clickableSpan );
517
+
518
+ final AccessibilityLinks .AccessibleLink link = mAccessibilityLinks .getLinkBySpanPos (start , end );
519
+ return link != null ? link .id : INVALID_ID ;
520
+ }
521
+
522
+ @ Override
523
+ protected void getVisibleVirtualViews (List <Integer > virtualViewIds ) {
524
+ if (mAccessibilityLinks == null ) {
525
+ return ;
526
+ }
527
+
528
+ for (int i = 0 ; i < mAccessibilityLinks .size (); i ++) {
529
+ virtualViewIds .add (i );
530
+ }
531
+ }
532
+
533
+ @ Override
534
+ protected void onPopulateNodeForVirtualView (
535
+ int virtualViewId , @ NonNull AccessibilityNodeInfoCompat node ) {
536
+ // If we get an invalid virtualViewId for some reason (which is known to happen in API 19 and
537
+ // below), return an "empty" node to prevent from crashing. This will never be presented to
538
+ // the user, as Talkback filters out nodes with no content to announce.
539
+ if (mAccessibilityLinks == null ) {
540
+ node .setContentDescription ("" );
541
+ node .setBoundsInParent (new Rect (0 , 0 , 1 , 1 ));
542
+ return ;
543
+ }
544
+
545
+ final AccessibilityLinks .AccessibleLink accessibleTextSpan =
546
+ mAccessibilityLinks .getLinkById (virtualViewId );
547
+ if (accessibleTextSpan == null ) {
548
+ node .setContentDescription ("" );
549
+ node .setBoundsInParent (new Rect (0 , 0 , 1 , 1 ));
550
+ return ;
551
+ }
552
+
553
+ node .setContentDescription (accessibleTextSpan .description );
554
+ node .addAction (AccessibilityNodeInfoCompat .ACTION_CLICK );
555
+ node .setBoundsInParent (getBoundsInParent (accessibleTextSpan ));
556
+ node .setRoleDescription (mView .getResources ().getString (R .string .link_description ));
557
+ node .setClassName (AccessibilityRole .getValue (AccessibilityRole .BUTTON ));
558
+ }
559
+
560
+ private Rect getBoundsInParent (AccessibilityLinks .AccessibleLink accessibleLink ) {
561
+ // This view is not a text view, so return the entire views bounds.
562
+ if (!(mView instanceof TextView )) {
563
+ return new Rect (0 , 0 , mView .getWidth (), mView .getHeight ());
564
+ }
565
+
566
+ TextView textView = (TextView ) mView ;
567
+ Layout textViewLayout = textView .getLayout ();
568
+ if (textViewLayout == null ) {
569
+ return new Rect (0 , 0 , textView .getWidth (), textView .getHeight ());
570
+ }
571
+
572
+ Rect rootRect = new Rect ();
573
+
574
+ double startOffset = accessibleLink .start ;
575
+ double endOffset = accessibleLink .end ;
576
+ double startXCoordinates = textViewLayout .getPrimaryHorizontal ((int ) startOffset );
577
+
578
+ final Paint paint = new Paint ();
579
+ AbsoluteSizeSpan sizeSpan =
580
+ getFirstSpan (accessibleLink .start , accessibleLink .end , AbsoluteSizeSpan .class );
581
+ float textSize = sizeSpan != null ? sizeSpan .getSize () : textView .getTextSize ();
582
+ paint .setTextSize (textSize );
583
+ int textWidth = (int ) Math .ceil (paint .measureText (accessibleLink .description ));
584
+
585
+ int startOffsetLineNumber = textViewLayout .getLineForOffset ((int ) startOffset );
586
+ int endOffsetLineNumber = textViewLayout .getLineForOffset ((int ) endOffset );
587
+ boolean isMultiline = startOffsetLineNumber != endOffsetLineNumber ;
588
+ textViewLayout .getLineBounds (startOffsetLineNumber , rootRect );
589
+
590
+ int verticalOffset = textView .getScrollY () + textView .getTotalPaddingTop ();
591
+ rootRect .top += verticalOffset ;
592
+ rootRect .bottom += verticalOffset ;
593
+ rootRect .left += startXCoordinates + textView .getTotalPaddingLeft () - textView .getScrollX ();
594
+
595
+ // The bounds for multi-line strings should *only* include the first line. This is because for
596
+ // API 25 and below, Talkback's click is triggered at the center point of these bounds, and if
597
+ // that center point is outside the spannable, it will click on something else. There is no
598
+ // harm in not outlining the wrapped part of the string, as the text for the whole string will
599
+ // be read regardless of the bounding box.
600
+ if (isMultiline ) {
601
+ return new Rect (rootRect .left , rootRect .top , rootRect .right , rootRect .bottom );
602
+ }
603
+
604
+ return new Rect (rootRect .left , rootRect .top , rootRect .left + textWidth , rootRect .bottom );
605
+ }
606
+
607
+ @ Override
608
+ protected boolean onPerformActionForVirtualView (
609
+ int virtualViewId , int action , @ Nullable Bundle arguments ) {
610
+ return false ;
611
+ }
612
+
613
+ protected @ Nullable <T > T getFirstSpan (int start , int end , Class <T > classType ) {
614
+ if (!(mView instanceof TextView ) || !(((TextView ) mView ).getText () instanceof Spanned )) {
615
+ return null ;
616
+ }
617
+
618
+ Spanned spanned = (Spanned ) ((TextView ) mView ).getText ();
619
+ T [] spans = spanned .getSpans (start , end , classType );
620
+ return spans .length > 0 ? spans [0 ] : null ;
621
+ }
622
+
623
+ public static class AccessibilityLinks {
624
+ private final List <AccessibleLink > mLinks ;
625
+
626
+ public AccessibilityLinks (ClickableSpan [] spans , Spannable text ) {
627
+ ArrayList <AccessibleLink > links = new ArrayList <>();
628
+ for (int i = 0 ; i < spans .length ; i ++) {
629
+ ClickableSpan span = spans [i ];
630
+ int start = text .getSpanStart (span );
631
+ int end = text .getSpanEnd (span );
632
+ // zero length spans, and out of range spans should not be included.
633
+ if (start == end || start < 0 || end < 0 || start > text .length () || end > text .length ()) {
634
+ continue ;
635
+ }
636
+
637
+ final AccessibleLink link = new AccessibleLink ();
638
+ link .description = text .subSequence (start , end ).toString ();
639
+ link .start = start ;
640
+ link .end = end ;
641
+
642
+ // ID is the reverse of what is expected, since the ClickableSpans are returned in reverse
643
+ // order due to being added in reverse order. If we don't do this, focus will move to the
644
+ // last link first and move backwards.
645
+ //
646
+ // If this approach becomes unreliable, we should instead look at their start position and
647
+ // order them manually.
648
+ link .id = spans .length - 1 - i ;
649
+ links .add (link );
650
+ }
651
+ mLinks = links ;
652
+ }
653
+
654
+ @ Nullable
655
+ public AccessibleLink getLinkById (int id ) {
656
+ for (AccessibleLink link : mLinks ) {
657
+ if (link .id == id ) {
658
+ return link ;
659
+ }
660
+ }
661
+
662
+ return null ;
663
+ }
664
+
665
+ @ Nullable
666
+ public AccessibleLink getLinkBySpanPos (int start , int end ) {
667
+ for (AccessibleLink link : mLinks ) {
668
+ if (link .start == start && link .end == end ) {
669
+ return link ;
670
+ }
671
+ }
672
+
673
+ return null ;
674
+ }
675
+
676
+ public int size () {
677
+ return mLinks .size ();
678
+ }
679
+
680
+ private static class AccessibleLink {
681
+ public String description ;
682
+ public int start ;
683
+ public int end ;
684
+ public int id ;
458
685
}
459
686
}
460
687
}
0 commit comments