Skip to content

Commit 834bd43

Browse files
hryhoriiK97claude
andcommitted
Keep the Text accessibility delegate when the text has no links
Every Text update reset the accessibility delegate, creating a new ExploreByTouchHelper even when TalkBack is off and the text has no links. Keep the existing delegate when the text has no links before or after the update, and make an "auto" view important as setting the delegate does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 60de64e commit 834bd43

4 files changed

Lines changed: 120 additions & 32 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt‎

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ package com.facebook.react.views.text
1212
import android.text.Spannable
1313
import android.text.Spanned
1414
import android.view.View
15-
import com.facebook.react.R
1615
import com.facebook.react.bridge.Dynamic
1716
import com.facebook.react.internal.SystraceSection
1817
import com.facebook.react.module.annotations.ReactModule
@@ -33,7 +32,6 @@ import com.facebook.react.uimanager.style.BorderRadiusProp
3332
import com.facebook.react.uimanager.style.BorderStyle
3433
import com.facebook.react.uimanager.style.LogicalEdge
3534
import com.facebook.react.uimanager.style.Overflow
36-
import com.facebook.react.views.text.ReactTextViewAccessibilityDelegate.AccessibilityLinks
3735
import com.facebook.react.views.view.ImportantForInteractionHelper
3836
import java.util.HashMap
3937

@@ -77,20 +75,9 @@ internal class PreparedLayoutTextViewManager :
7775
val preparedLayout = extraData as PreparedLayout
7876
view.preparedLayout = preparedLayout
7977

80-
// If this text view contains any clickable spans, set a view tag and reset the accessibility
81-
// delegate so that these can be picked up by the accessibility system.
82-
if (preparedLayout.layout.text is Spanned) {
83-
val spannedText = preparedLayout.layout.text as Spanned
84-
val accessibilityLinks = AccessibilityLinks(spannedText)
85-
view.setTag(
86-
R.id.accessibility_links,
87-
if (accessibilityLinks.size() > 0) accessibilityLinks else null,
88-
)
89-
ReactTextViewAccessibilityDelegate.resetDelegate(
90-
view,
91-
view.isFocusable,
92-
view.importantForAccessibility,
93-
)
78+
val text = preparedLayout.layout.text
79+
if (text is Spanned) {
80+
ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, text)
9481
}
9582
}
9683
}

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ internal class ReactTextViewAccessibilityDelegate(
5959
}
6060
}
6161

62-
fun resetDelegate(view: View, originalFocus: Boolean, originalImportantForAccessibility: Int) {
62+
private fun resetDelegate(
63+
view: View,
64+
originalFocus: Boolean,
65+
originalImportantForAccessibility: Int,
66+
) {
6367
ViewCompat.setAccessibilityDelegate(
6468
view,
6569
ReactTextViewAccessibilityDelegate(
@@ -69,6 +73,33 @@ internal class ReactTextViewAccessibilityDelegate(
6973
),
7074
)
7175
}
76+
77+
/**
78+
* Stores the links among the clickable spans of [text] in a view tag and resets the
79+
* accessibility delegate so that these can be picked up by the accessibility system. A view
80+
* that already has this delegate keeps it when the text has no links, before or after the
81+
* update, as the delegate only reads the links when it's created.
82+
*/
83+
fun updateAccessibilityLinks(view: View, text: Spanned) {
84+
val accessibilityLinks = AccessibilityLinks(text)
85+
val hadLinks = view.getTag(R.id.accessibility_links) != null
86+
view.setTag(
87+
R.id.accessibility_links,
88+
if (accessibilityLinks.size() > 0) accessibilityLinks else null,
89+
)
90+
if (
91+
accessibilityLinks.size() == 0 &&
92+
!hadLinks &&
93+
ViewCompat.getAccessibilityDelegate(view) is ReactTextViewAccessibilityDelegate
94+
) {
95+
// Like setting the delegate through ViewCompat, which makes an "auto" view important.
96+
if (view.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
97+
view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
98+
}
99+
return
100+
}
101+
resetDelegate(view, view.isFocusable, view.importantForAccessibility)
102+
}
72103
}
73104

74105
override fun onVirtualViewKeyboardFocusChanged(virtualViewId: Int, hasFocus: Boolean) {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt‎

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import android.text.TextUtils
1818
import android.text.util.Linkify
1919
import android.view.Gravity
2020
import com.facebook.common.logging.FLog
21-
import com.facebook.react.R
2221
import com.facebook.react.bridge.Dynamic
2322
import com.facebook.react.common.ReactConstants
2423
import com.facebook.react.common.annotations.UnstableReactNativeAPI
@@ -99,20 +98,7 @@ public constructor(
9998
val update = extraData as ReactTextUpdate
10099
val spanned: Spanned = update.text
101100
view.setText(update)
102-
103-
// If this text view contains any clickable spans, set a view tag and reset the accessibility
104-
// delegate so that these can be picked up by the accessibility system.
105-
val accessibilityLinks: ReactTextViewAccessibilityDelegate.AccessibilityLinks =
106-
ReactTextViewAccessibilityDelegate.AccessibilityLinks(spanned)
107-
view.setTag(
108-
R.id.accessibility_links,
109-
if (accessibilityLinks.size() > 0) accessibilityLinks else null,
110-
)
111-
ReactTextViewAccessibilityDelegate.resetDelegate(
112-
view,
113-
view.isFocusable,
114-
view.importantForAccessibility,
115-
)
101+
ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, spanned)
116102
}
117103
}
118104

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.text
9+
10+
import android.text.SpannableString
11+
import android.text.Spanned
12+
import android.text.style.ClickableSpan
13+
import android.view.View
14+
import androidx.core.view.AccessibilityDelegateCompat
15+
import androidx.core.view.ViewCompat
16+
import com.facebook.react.R
17+
import org.assertj.core.api.Assertions.assertThat
18+
import org.junit.Test
19+
import org.junit.runner.RunWith
20+
import org.robolectric.RobolectricTestRunner
21+
import org.robolectric.RuntimeEnvironment
22+
23+
@RunWith(RobolectricTestRunner::class)
24+
class ReactTextViewAccessibilityDelegateTest {
25+
26+
private val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication())
27+
28+
@Test
29+
fun `the first update installs the text delegate`() {
30+
ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("Hello"))
31+
32+
assertThat(delegate()).isInstanceOf(ReactTextViewAccessibilityDelegate::class.java)
33+
assertThat(view.getTag(R.id.accessibility_links)).isNull()
34+
}
35+
36+
@Test
37+
fun `updates without links keep the delegate`() {
38+
ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("Hello"))
39+
val first = delegate()
40+
41+
ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("World"))
42+
43+
assertThat(delegate()).isSameAs(first)
44+
}
45+
46+
@Test
47+
fun `adding and removing links resets the delegate`() {
48+
ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("Hello"))
49+
val plain = delegate()
50+
51+
ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, textWithLink())
52+
val withLink = delegate()
53+
assertThat(withLink).isNotSameAs(plain)
54+
assertThat(view.getTag(R.id.accessibility_links)).isNotNull()
55+
56+
ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("Hello"))
57+
assertThat(delegate()).isNotSameAs(withLink)
58+
assertThat(view.getTag(R.id.accessibility_links)).isNull()
59+
}
60+
61+
@Test
62+
fun `a view set back to auto is important for accessibility after an update`() {
63+
ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("Hello"))
64+
view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
65+
66+
ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("World"))
67+
68+
assertThat(view.importantForAccessibility).isEqualTo(View.IMPORTANT_FOR_ACCESSIBILITY_YES)
69+
}
70+
71+
private fun delegate(): AccessibilityDelegateCompat? = ViewCompat.getAccessibilityDelegate(view)
72+
73+
private fun textWithLink(): Spanned =
74+
SpannableString("Read the docs here").apply {
75+
setSpan(
76+
object : ClickableSpan() {
77+
override fun onClick(widget: View) = Unit
78+
},
79+
9,
80+
13,
81+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
82+
)
83+
}
84+
}

0 commit comments

Comments
 (0)