Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Loading