Skip to content

Commit 72e0244

Browse files
Preserve clickable spans in Android text accessibility
1 parent 5cbf845 commit 72e0244

2 files changed

Lines changed: 125 additions & 63 deletions

File tree

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

Lines changed: 41 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,7 @@ import android.os.Bundle
1212
import android.text.Layout
1313
import android.text.SpannableString
1414
import android.text.Spanned
15-
import android.text.style.AbsoluteSizeSpan
16-
import android.text.style.BackgroundColorSpan
1715
import android.text.style.ClickableSpan
18-
import android.text.style.ForegroundColorSpan
19-
import android.text.style.StrikethroughSpan
20-
import android.text.style.StyleSpan
21-
import android.text.style.URLSpan
22-
import android.text.style.UnderlineSpan
2316
import android.view.View
2417
import android.widget.TextView
2518
import androidx.core.view.ViewCompat
@@ -28,18 +21,7 @@ import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
2821
import com.facebook.react.R
2922
import com.facebook.react.common.annotations.UnstableReactNativeAPI
3023
import com.facebook.react.uimanager.ReactAccessibilityDelegate
31-
import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan
32-
import com.facebook.react.views.text.internal.span.CustomLineHeightSpan
33-
import com.facebook.react.views.text.internal.span.CustomStyleSpan
34-
import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan
35-
import com.facebook.react.views.text.internal.span.ReactBackgroundColorSpan
3624
import com.facebook.react.views.text.internal.span.ReactClickableSpan
37-
import com.facebook.react.views.text.internal.span.ReactForegroundColorSpan
38-
import com.facebook.react.views.text.internal.span.ReactLinkSpan
39-
import com.facebook.react.views.text.internal.span.ReactOpacitySpan
40-
import com.facebook.react.views.text.internal.span.ReactStrikethroughSpan
41-
import com.facebook.react.views.text.internal.span.ReactUnderlineSpan
42-
import com.facebook.react.views.text.internal.span.ShadowStyleSpan
4325

4426
@OptIn(UnstableReactNativeAPI::class)
4527
internal class ReactTextViewAccessibilityDelegate(
@@ -208,7 +190,7 @@ internal class ReactTextViewAccessibilityDelegate(
208190
// PreparedLayoutTextView isn't actually a TextView, so we need to teach it about its text that
209191
// it is holding so TalkBack knows what to announce when focusing it.
210192
val accessibilityText = if (host is PreparedLayoutTextView) host.text else info.text
211-
info.text = accessibilityText.toAccessibilityTextWithoutVisualSpans()
193+
info.text = accessibilityText.toAccessibilityTextWithClickableSpans()
212194
}
213195

214196
@Suppress("DEPRECATION")
@@ -383,42 +365,52 @@ private fun isWholeTextSingleLink(text: Spanned, spans: Array<ClickableSpan>): B
383365
return start == 0 && end == text.length
384366
}
385367

386-
private fun CharSequence?.toAccessibilityTextWithoutVisualSpans(): CharSequence? {
368+
private const val PARCEL_SAFE_TEXT_LENGTH = 100_000
369+
370+
private fun CharSequence?.toAccessibilityTextWithClickableSpans(): CharSequence? {
371+
if (this == null) {
372+
return null
373+
}
374+
375+
val trimmedText = toString().trimToParcelableSize()
387376
if (this !is Spanned) {
388-
return this
377+
return trimmedText
389378
}
390379

391-
return SpannableString(this).apply {
392-
getSpans(0, length, Any::class.java)
393-
.filter { isVisualSpanForAccessibility(it) }
394-
.forEach { removeSpan(it) }
380+
val retainedLength = trimmedText.length
381+
val clickableSpans =
382+
getSpans(0, length, ClickableSpan::class.java).filter { span ->
383+
val start = getSpanStart(span)
384+
val end = getSpanEnd(span)
385+
start >= 0 && end >= 0 && start != end && start < retainedLength && end > 0
386+
}
387+
388+
if (clickableSpans.isEmpty()) {
389+
return trimmedText
390+
}
391+
392+
val sourceText = this
393+
return SpannableString(trimmedText).apply {
394+
for (span in clickableSpans) {
395+
val start = sourceText.getSpanStart(span).coerceAtLeast(0)
396+
val end = sourceText.getSpanEnd(span).coerceAtMost(retainedLength)
397+
if (start < end) {
398+
setSpan(span, start, end, sourceText.getSpanFlags(span))
399+
}
400+
}
395401
}
396402
}
397403

398-
private fun isVisualSpanForAccessibility(span: Any): Boolean {
399-
if (
400-
span is URLSpan ||
401-
span is ReactClickableSpan ||
402-
span is ReactLinkSpan ||
403-
span is ClickableSpan
404-
) {
405-
return false
404+
private fun String.trimToParcelableSize(): String {
405+
if (length <= PARCEL_SAFE_TEXT_LENGTH) {
406+
return this
406407
}
407408

408-
return span is ReactAbsoluteSizeSpan ||
409-
span is ReactForegroundColorSpan ||
410-
span is ReactBackgroundColorSpan ||
411-
span is CustomStyleSpan ||
412-
span is CustomLetterSpacingSpan ||
413-
span is CustomLineHeightSpan ||
414-
span is ReactOpacitySpan ||
415-
span is ShadowStyleSpan ||
416-
span is ReactUnderlineSpan ||
417-
span is ReactStrikethroughSpan ||
418-
span is AbsoluteSizeSpan ||
419-
span is ForegroundColorSpan ||
420-
span is BackgroundColorSpan ||
421-
span is StyleSpan ||
422-
span is UnderlineSpan ||
423-
span is StrikethroughSpan
409+
val end =
410+
if (Character.isHighSurrogate(this[PARCEL_SAFE_TEXT_LENGTH - 1])) {
411+
PARCEL_SAFE_TEXT_LENGTH - 1
412+
} else {
413+
PARCEL_SAFE_TEXT_LENGTH
414+
}
415+
return substring(0, end)
424416
}

packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,13 @@ class ReactTextViewAccessibilityDelegateTest {
7777
}
7878

7979
@Test
80-
fun reactTextViewAccessibilityNodeText_preservesMixedClickableAndUrlSpans() {
80+
fun reactTextViewAccessibilityNodeText_preservesMixedClickableAndVisualSpans() {
8181
val clickableSpan =
8282
object : ClickableSpan() {
8383
override fun onClick(widget: View) = Unit
8484
}
85-
val urlSpan = URLSpan("https://reactnative.dev")
8685
val text = createStyledText("Read docs now")
8786
text.setSpan(clickableSpan, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
88-
text.setSpan(urlSpan, 0, 4, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
8987
val textView = createReactTextView(text)
9088

9189
val nodeInfo = createNodeInfo(textView)
@@ -96,11 +94,25 @@ class ReactTextViewAccessibilityDelegateTest {
9694
assertThat(nodeInfo.text.toString()).isEqualTo("Read docs now")
9795
assertAccessibilityTextDoesNotHaveVisualSpans(accessibilityText)
9896
assertPreservedSpanMatchesSource(sourceText, accessibilityText, clickableSpan)
99-
assertPreservedSpanMatchesSource(sourceText, accessibilityText, urlSpan)
10097
}
10198

10299
@Test
103-
fun preparedLayoutTextViewAccessibilityNodeText_stripsStyleSpansAndPreservesClickableSpan() {
100+
fun reactTextViewAccessibilityNodeText_preservesUrlSpanSemantics() {
101+
val urlSpan = URLSpan("https://reactnative.dev")
102+
val text = createStyledText("React Native")
103+
text.setSpan(urlSpan, 0, 5, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
104+
val textView = createReactTextView(text)
105+
106+
val nodeInfo = createNodeInfo(textView)
107+
val accessibilityText = nodeInfo.text as Spanned
108+
109+
assertThat(nodeInfo.text.toString()).isEqualTo("React Native")
110+
assertAccessibilityTextDoesNotHaveVisualSpans(accessibilityText)
111+
assertAccessibilityTextHasClickableSpan(accessibilityText, 0, 5, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
112+
}
113+
114+
@Test
115+
fun preparedLayoutTextViewAccessibilityNodeText_keepsOnlyClickableSpans() {
104116
val text = createStyledText("Prepared text")
105117
val clickableSpan =
106118
object : ClickableSpan() {
@@ -134,6 +146,37 @@ class ReactTextViewAccessibilityDelegateTest {
134146
assertPreservedSpanMatchesSource(textView.text as Spanned, accessibilityText, clickableSpan)
135147
}
136148

149+
@Test
150+
fun reactTextViewAccessibilityNodeText_trimsLongTextWithoutSplittingSurrogatePairs() {
151+
val crossingBoundarySpan =
152+
object : ClickableSpan() {
153+
override fun onClick(widget: View) = Unit
154+
}
155+
val outsideRetainedTextSpan =
156+
object : ClickableSpan() {
157+
override fun onClick(widget: View) = Unit
158+
}
159+
val text = SpannableString("${"a".repeat(99_999)}\uD83D\uDE00b")
160+
text.setSpan(crossingBoundarySpan, 99_998, 100_001, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
161+
text.setSpan(outsideRetainedTextSpan, 100_001, 100_002, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
162+
val textView = createReactTextView(text)
163+
164+
val nodeInfo = createNodeInfo(textView)
165+
val accessibilityText = nodeInfo.text as Spanned
166+
167+
assertThat(nodeInfo.text.length).isEqualTo(99_999)
168+
assertThat(nodeInfo.text.toString()).doesNotEndWith("\uD83D")
169+
assertPreservedSpanMatchesRange(
170+
accessibilityText,
171+
crossingBoundarySpan,
172+
99_998,
173+
99_999,
174+
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
175+
)
176+
assertThat(accessibilityText.getSpans(0, accessibilityText.length, ClickableSpan::class.java))
177+
.doesNotContain(outsideRetainedTextSpan)
178+
}
179+
137180
private fun createReactTextViewWithStyledText(text: String): ReactTextView {
138181
return createReactTextView(createStyledText(text))
139182
}
@@ -194,18 +237,45 @@ class ReactTextViewAccessibilityDelegateTest {
194237
sourceText: Spanned,
195238
accessibilityText: Spanned,
196239
sourceSpan: Any,
240+
) {
241+
assertPreservedSpanMatchesRange(
242+
accessibilityText,
243+
sourceSpan,
244+
sourceText.getSpanStart(sourceSpan),
245+
sourceText.getSpanEnd(sourceSpan),
246+
sourceText.getSpanFlags(sourceSpan),
247+
)
248+
}
249+
250+
private fun assertPreservedSpanMatchesRange(
251+
accessibilityText: Spanned,
252+
sourceSpan: Any,
253+
start: Int,
254+
end: Int,
255+
flags: Int,
197256
) {
198257
val preservedSpans =
199258
accessibilityText
200-
.getSpans(
201-
sourceText.getSpanStart(sourceSpan),
202-
sourceText.getSpanEnd(sourceSpan),
203-
sourceSpan.javaClass,
204-
)
205-
.filter { accessibilityText.getSpanStart(it) == sourceText.getSpanStart(sourceSpan) }
206-
.filter { accessibilityText.getSpanEnd(it) == sourceText.getSpanEnd(sourceSpan) }
207-
.filter { accessibilityText.getSpanFlags(it) == sourceText.getSpanFlags(sourceSpan) }
208-
259+
.getSpans(start, end, sourceSpan.javaClass)
260+
.filter { accessibilityText.getSpanStart(it) == start }
261+
.filter { accessibilityText.getSpanEnd(it) == end }
262+
.filter { accessibilityText.getSpanFlags(it) == flags }
209263
assertThat(preservedSpans).isNotEmpty()
210264
}
265+
266+
private fun assertAccessibilityTextHasClickableSpan(
267+
accessibilityText: Spanned,
268+
start: Int,
269+
end: Int,
270+
flags: Int,
271+
) {
272+
val clickableSpans =
273+
accessibilityText
274+
.getSpans(start, end, ClickableSpan::class.java)
275+
.filter { accessibilityText.getSpanStart(it) == start }
276+
.filter { accessibilityText.getSpanEnd(it) == end }
277+
.filter { accessibilityText.getSpanFlags(it) == flags }
278+
279+
assertThat(clickableSpans).isNotEmpty()
280+
}
211281
}

0 commit comments

Comments
 (0)