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 03669591952..ba3b7af1899 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 6834a329ac6..d8c07ebdd85 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 ef7c3a33b04..c513c2e9af2 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 00000000000..7dd6991f5c0 --- /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, + ) + } +}