From 834bd43677d7223f2893c8a522b465630b7f7424 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 26 Oct 2026 23:35:12 +0100 Subject: [PATCH] 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 --- .../text/PreparedLayoutTextViewManager.kt | 19 +---- .../ReactTextViewAccessibilityDelegate.kt | 33 +++++++- .../react/views/text/ReactTextViewManager.kt | 16 +--- .../ReactTextViewAccessibilityDelegateTest.kt | 84 +++++++++++++++++++ 4 files changed, 120 insertions(+), 32 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt index 036695919522..ba3b7af18994 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt @@ -12,7 +12,6 @@ package com.facebook.react.views.text import android.text.Spannable import android.text.Spanned import android.view.View -import com.facebook.react.R import com.facebook.react.bridge.Dynamic import com.facebook.react.internal.SystraceSection import com.facebook.react.module.annotations.ReactModule @@ -33,7 +32,6 @@ import com.facebook.react.uimanager.style.BorderRadiusProp import com.facebook.react.uimanager.style.BorderStyle import com.facebook.react.uimanager.style.LogicalEdge import com.facebook.react.uimanager.style.Overflow -import com.facebook.react.views.text.ReactTextViewAccessibilityDelegate.AccessibilityLinks import com.facebook.react.views.view.ImportantForInteractionHelper import java.util.HashMap @@ -77,20 +75,9 @@ internal class PreparedLayoutTextViewManager : val preparedLayout = extraData as PreparedLayout view.preparedLayout = preparedLayout - // If this text view contains any clickable spans, set a view tag and reset the accessibility - // delegate so that these can be picked up by the accessibility system. - if (preparedLayout.layout.text is Spanned) { - val spannedText = preparedLayout.layout.text as Spanned - val accessibilityLinks = AccessibilityLinks(spannedText) - view.setTag( - R.id.accessibility_links, - if (accessibilityLinks.size() > 0) accessibilityLinks else null, - ) - ReactTextViewAccessibilityDelegate.resetDelegate( - view, - view.isFocusable, - view.importantForAccessibility, - ) + val text = preparedLayout.layout.text + if (text is Spanned) { + ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, text) } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt index 6834a329ac6c..d8c07ebdd854 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt @@ -59,7 +59,11 @@ internal class ReactTextViewAccessibilityDelegate( } } - fun resetDelegate(view: View, originalFocus: Boolean, originalImportantForAccessibility: Int) { + private fun resetDelegate( + view: View, + originalFocus: Boolean, + originalImportantForAccessibility: Int, + ) { ViewCompat.setAccessibilityDelegate( view, ReactTextViewAccessibilityDelegate( @@ -69,6 +73,33 @@ internal class ReactTextViewAccessibilityDelegate( ), ) } + + /** + * Stores the links among the clickable spans of [text] in a view tag and resets the + * accessibility delegate so that these can be picked up by the accessibility system. A view + * that already has this delegate keeps it when the text has no links, before or after the + * update, as the delegate only reads the links when it's created. + */ + fun updateAccessibilityLinks(view: View, text: Spanned) { + val accessibilityLinks = AccessibilityLinks(text) + val hadLinks = view.getTag(R.id.accessibility_links) != null + view.setTag( + R.id.accessibility_links, + if (accessibilityLinks.size() > 0) accessibilityLinks else null, + ) + if ( + accessibilityLinks.size() == 0 && + !hadLinks && + ViewCompat.getAccessibilityDelegate(view) is ReactTextViewAccessibilityDelegate + ) { + // Like setting the delegate through ViewCompat, which makes an "auto" view important. + if (view.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_AUTO) { + view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + } + return + } + resetDelegate(view, view.isFocusable, view.importantForAccessibility) + } } override fun onVirtualViewKeyboardFocusChanged(virtualViewId: Int, hasFocus: Boolean) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index ef7c3a33b04e..c513c2e9af27 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -18,7 +18,6 @@ import android.text.TextUtils import android.text.util.Linkify import android.view.Gravity import com.facebook.common.logging.FLog -import com.facebook.react.R import com.facebook.react.bridge.Dynamic import com.facebook.react.common.ReactConstants import com.facebook.react.common.annotations.UnstableReactNativeAPI @@ -99,20 +98,7 @@ public constructor( val update = extraData as ReactTextUpdate val spanned: Spanned = update.text view.setText(update) - - // If this text view contains any clickable spans, set a view tag and reset the accessibility - // delegate so that these can be picked up by the accessibility system. - val accessibilityLinks: ReactTextViewAccessibilityDelegate.AccessibilityLinks = - ReactTextViewAccessibilityDelegate.AccessibilityLinks(spanned) - view.setTag( - R.id.accessibility_links, - if (accessibilityLinks.size() > 0) accessibilityLinks else null, - ) - ReactTextViewAccessibilityDelegate.resetDelegate( - view, - view.isFocusable, - view.importantForAccessibility, - ) + ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, spanned) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt new file mode 100644 index 000000000000..7dd6991f5c01 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt @@ -0,0 +1,84 @@ +/* + * 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.text.SpannableString +import android.text.Spanned +import android.text.style.ClickableSpan +import android.view.View +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import com.facebook.react.R +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ReactTextViewAccessibilityDelegateTest { + + private val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + @Test + fun `the first update installs the text delegate`() { + ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("Hello")) + + assertThat(delegate()).isInstanceOf(ReactTextViewAccessibilityDelegate::class.java) + assertThat(view.getTag(R.id.accessibility_links)).isNull() + } + + @Test + fun `updates without links keep the delegate`() { + ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("Hello")) + val first = delegate() + + ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("World")) + + assertThat(delegate()).isSameAs(first) + } + + @Test + fun `adding and removing links resets the delegate`() { + ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("Hello")) + val plain = delegate() + + ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, textWithLink()) + val withLink = delegate() + assertThat(withLink).isNotSameAs(plain) + assertThat(view.getTag(R.id.accessibility_links)).isNotNull() + + ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("Hello")) + assertThat(delegate()).isNotSameAs(withLink) + assertThat(view.getTag(R.id.accessibility_links)).isNull() + } + + @Test + fun `a view set back to auto is important for accessibility after an update`() { + ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("Hello")) + view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_AUTO + + ReactTextViewAccessibilityDelegate.updateAccessibilityLinks(view, SpannableString("World")) + + assertThat(view.importantForAccessibility).isEqualTo(View.IMPORTANT_FOR_ACCESSIBILITY_YES) + } + + private fun delegate(): AccessibilityDelegateCompat? = ViewCompat.getAccessibilityDelegate(view) + + private fun textWithLink(): Spanned = + SpannableString("Read the docs here").apply { + setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) = Unit + }, + 9, + 13, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } +}