Skip to content

Commit e8ebd13

Browse files
javachefacebook-github-bot
authored andcommitted
Expose full logical text from PreparedLayoutTextView to a11y/view hierarchy
Summary: ## Summary `PreparedLayoutTextView` currently derives its exposed text from `Layout.getText()`. When Android ellipsizes a `StaticLayout`, that value is backed by `TextUtils.Ellipsizer`, whose string representation contains the rendered ellipsis instead of the complete source text. Accessibility services and view-hierarchy consumers can therefore receive truncated content. Preserve the original `CharSequence` in `PreparedLayout` and expose it from `PreparedLayoutTextView.getText()`. Drawing, hit testing, span handling, and selection continue to use the rendered layout text, so visual ellipsizing behaviour is unchanged. Changelog: [Android][Fixed] - Preserve complete text for accessibility when prepared text layouts are ellipsized Reviewed By: derekmeta Differential Revision: D125204355
1 parent 96dddb0 commit e8ebd13

7 files changed

Lines changed: 139 additions & 87 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,8 @@ public PreparedLayout reusePreparedLayoutWithNewReactTags(
713713
reactTags,
714714
preparedLayout.getTextBreakStrategy(),
715715
preparedLayout.getJustificationMode(),
716-
preparedLayout.getBaseTextAttributes());
716+
preparedLayout.getBaseTextAttributes(),
717+
preparedLayout.getSourceText());
717718
}
718719

719720
@AnyThread

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,9 @@ internal class PreparedLayout(
2323
val textBreakStrategy: Int,
2424
val justificationMode: Int,
2525
val baseTextAttributes: TextAttributeProps,
26+
// The full, un-ellipsized logical text. Required (no default) because when the layout is
27+
// ellipsized `layout.text` is an Android `Ellipsizer` whose `toString()` is only the on-screen
28+
// (truncated) text; every caller must consciously supply the complete string so accessibility
29+
// and view-hierarchy consumers never fall back to the truncated text.
30+
val sourceText: CharSequence,
2631
)

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
9292

9393
val text: CharSequence?
9494
// Avoid mangling the getter name, to allow black box E2E tests to read text content via
95-
// reflection
96-
@DoNotStrip get() = preparedLayout?.layout?.text
95+
// reflection. Returns the full logical text, not `layout.text`, which can be an ellipsized
96+
// `Ellipsizer` exposing only the on-screen (truncated) string to accessibility and the view
97+
// hierarchy; internal rendering/hit-testing continues to use `layout.text` directly.
98+
@DoNotStrip get() = preparedLayout?.sourceText
9799

98100
init {
99101
// ViewGroup by default says only its children will draw
@@ -136,7 +138,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
136138
selectionColor ?: DefaultStyleValuesUtil.getDefaultTextColorHighlight(context)
137139
}
138140

139-
val spanned = text as? Spanned
141+
val spanned = layout.text as? Spanned
140142
val canvasEffectSpans =
141143
spanned?.getSpans(0, spanned.length, CanvasEffectSpan::class.java) ?: emptyArray()
142144

@@ -242,7 +244,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
242244
val action = event.actionMasked
243245
if (action == MotionEvent.ACTION_CANCEL) {
244246
// Forward ACTION_CANCEL to all TouchableSpans so they can reset pressed/animation state
245-
val spanned = text as? Spanned
247+
val spanned = preparedLayout?.layout?.text as? Spanned
246248
for (span in spanned?.getSpans(0, spanned.length, TouchableSpan::class.java).orEmpty()) {
247249
span.onTouchEvent(action, 0f, 0f)
248250
}
@@ -314,7 +316,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
314316
return null
315317
}
316318

317-
val spanned = text as? Spanned ?: return null
319+
val spanned = preparedLayout?.layout?.text as? Spanned ?: return null
318320

319321
val spans = spanned.getSpans(offset, offset, clazz)
320322
if (spans.isEmpty()) {
@@ -503,6 +505,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
503505
textBreakStrategy,
504506
justificationMode,
505507
baseTextAttributes,
508+
sourceText,
506509
)
507510
}
508511
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -841,7 +841,8 @@ internal object TextLayoutManager {
841841
}
842842
}
843843

844-
private fun createLayout(
844+
@VisibleForTesting
845+
internal fun createLayout(
845846
text: Spannable,
846847
boring: BoringLayout.Metrics?,
847848
width: Float,
@@ -1356,6 +1357,8 @@ internal object TextLayoutManager {
13561357
result.textBreakStrategy,
13571358
result.justificationMode,
13581359
baseTextAttributes,
1360+
// `result.layout.text` may be ellipsized; keep the full text for a11y.
1361+
sourceText = text,
13591362
)
13601363
}
13611364

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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.annotation.SuppressLint
11+
import android.text.Layout
12+
import android.text.SpannableString
13+
import android.text.TextPaint
14+
import android.text.TextUtils
15+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
16+
import com.facebook.react.common.mapbuffer.WritableMapBuffer
17+
import com.facebook.yoga.YogaMeasureMode
18+
import org.assertj.core.api.Assertions.assertThat
19+
import org.junit.Test
20+
import org.junit.runner.RunWith
21+
import org.robolectric.RobolectricTestRunner
22+
import org.robolectric.RuntimeEnvironment
23+
import org.robolectric.annotation.Config
24+
25+
/**
26+
* Regression test for prepared text layout exposing truncated text to accessibility / the view
27+
* hierarchy.
28+
*
29+
* When a prepared [Layout] is ellipsized, `layout.text` is an Android `Ellipsizer` whose
30+
* `toString()` is only the on-screen (truncated) string. [PreparedLayoutTextView.getText] — read by
31+
* the accessibility node and by black-box E2E view-hierarchy tooling — must instead return the full
32+
* logical text, matching a non-prepared `TextView` whose `getText()` returns the full source.
33+
*/
34+
@OptIn(UnstableReactNativeAPI::class)
35+
@RunWith(RobolectricTestRunner::class)
36+
@SuppressLint("NewApi")
37+
class PreparedLayoutTextViewAccessibilityTextTest {
38+
39+
private val fullText = listOf(
40+
"This is an order created from the data builder",
41+
"second line of the description",
42+
"third line of the description",
43+
)
44+
.joinToString("\n")
45+
46+
@Test
47+
@Config(sdk = [34])
48+
fun `getText returns the full logical text when the prepared layout is ellipsized`() {
49+
val layout = createEllipsizedLayout()
50+
51+
// Guard: the scenario is genuinely ellipsized, so layout.text is the truncated on-screen text.
52+
assertThat(layout.getEllipsisCount(layout.lineCount - 1)).isGreaterThan(0)
53+
assertThat(layout.text.toString()).contains("…")
54+
assertThat(layout.text.toString()).isNotEqualTo(fullText)
55+
56+
val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication())
57+
view.preparedLayout =
58+
PreparedLayout(
59+
layout,
60+
/* maximumNumberOfLines = */ 1,
61+
/* verticalOffset = */ 0f,
62+
/* reactTags = */ IntArray(0),
63+
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
64+
/* justificationMode = */ 0,
65+
baseTextAttributes = TextAttributeProps.fromMapBuffer(WritableMapBuffer()),
66+
/* sourceText = */ fullText,
67+
)
68+
69+
assertThat(view.text?.toString()).isEqualTo(fullText)
70+
assertThat(view.text?.toString()).doesNotContain("…")
71+
}
72+
73+
private fun createEllipsizedLayout(): Layout =
74+
TextLayoutManager.createLayout(
75+
SpannableString(fullText),
76+
/* boring = */ null,
77+
/* width = */ 200f,
78+
YogaMeasureMode.EXACTLY,
79+
/* includeFontPadding = */ false,
80+
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
81+
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
82+
Layout.Alignment.ALIGN_NORMAL,
83+
/* justificationMode = */ 0,
84+
/* ellipsizeMode = */ TextUtils.TruncateAt.END,
85+
/* maxNumberOfLines = */ 1,
86+
TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f },
87+
)
88+
}

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

Lines changed: 17 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import android.text.BoringLayout
1212
import android.text.Layout
1313
import android.text.SpannableString
1414
import android.text.TextPaint
15-
import android.text.TextUtils
1615
import com.facebook.yoga.YogaMeasureMode
1716
import kotlin.math.ceil
1817
import org.assertj.core.api.Assertions.assertThat
@@ -78,9 +77,8 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest {
7877
}
7978

8079
/**
81-
* Invokes the private TextLayoutManager.createLayout via reflection. We can't call it directly
82-
* because it's `private` (friend_paths only opens up `internal`). Default values mirror what
83-
* measureText() passes in the production path for a plain single-paragraph label.
80+
* Default values mirror what measureText() passes in the production path for a plain
81+
* single-paragraph label.
8482
*
8583
* BREAK_STRATEGY_HIGH_QUALITY and HYPHENATION_FREQUENCY_NONE are API 23+ constants. The test runs
8684
* only on Robolectric (JVM), never on a device, so the inlined integer values are safe.
@@ -90,42 +88,19 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest {
9088
text: SpannableString,
9189
width: Float,
9290
paint: TextPaint,
93-
): Layout {
94-
val boring: BoringLayout.Metrics? = BoringLayout.isBoring(text, paint)
95-
val method =
96-
TextLayoutManager::class
97-
.java
98-
.getDeclaredMethod(
99-
"createLayout",
100-
android.text.Spannable::class.java,
101-
BoringLayout.Metrics::class.java,
102-
java.lang.Float.TYPE,
103-
YogaMeasureMode::class.java,
104-
java.lang.Boolean.TYPE,
105-
java.lang.Integer.TYPE,
106-
java.lang.Integer.TYPE,
107-
Layout.Alignment::class.java,
108-
java.lang.Integer.TYPE,
109-
TextUtils.TruncateAt::class.java,
110-
java.lang.Integer.TYPE,
111-
TextPaint::class.java,
112-
)
113-
.apply { isAccessible = true }
114-
115-
return method.invoke(
116-
TextLayoutManager,
117-
text,
118-
boring,
119-
width,
120-
YogaMeasureMode.EXACTLY,
121-
/* includeFontPadding = */ false,
122-
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
123-
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
124-
Layout.Alignment.ALIGN_NORMAL,
125-
/* justificationMode = */ 0,
126-
/* ellipsizeMode = */ null,
127-
/* maxNumberOfLines = */ 2,
128-
paint,
129-
) as Layout
130-
}
91+
): Layout =
92+
TextLayoutManager.createLayout(
93+
text,
94+
BoringLayout.isBoring(text, paint),
95+
width,
96+
YogaMeasureMode.EXACTLY,
97+
/* includeFontPadding = */ false,
98+
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
99+
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
100+
Layout.Alignment.ALIGN_NORMAL,
101+
/* justificationMode = */ 0,
102+
/* ellipsizeMode = */ null,
103+
/* maxNumberOfLines = */ 2,
104+
paint,
105+
)
131106
}

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

Lines changed: 15 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ package com.facebook.react.views.text
99

1010
import android.annotation.SuppressLint
1111
import android.graphics.RectF
12-
import android.text.BoringLayout
1312
import android.text.Layout
1413
import android.text.SpannableString
1514
import android.text.TextPaint
@@ -132,43 +131,21 @@ class TextLayoutManagerStartOverhangTest {
132131
ellipsizeMode: TextUtils.TruncateAt? = null,
133132
maxNumberOfLines: Int = 2,
134133
paint: TextPaint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f },
135-
): Layout {
136-
val method =
137-
TextLayoutManager::class
138-
.java
139-
.getDeclaredMethod(
140-
"createLayout",
141-
android.text.Spannable::class.java,
142-
BoringLayout.Metrics::class.java,
143-
java.lang.Float.TYPE,
144-
YogaMeasureMode::class.java,
145-
java.lang.Boolean.TYPE,
146-
java.lang.Integer.TYPE,
147-
java.lang.Integer.TYPE,
148-
Layout.Alignment::class.java,
149-
java.lang.Integer.TYPE,
150-
TextUtils.TruncateAt::class.java,
151-
java.lang.Integer.TYPE,
152-
TextPaint::class.java,
153-
)
154-
.apply { isAccessible = true }
155-
156-
return method.invoke(
157-
TextLayoutManager,
158-
text,
159-
null,
160-
layoutWidth,
161-
widthMode,
162-
/* includeFontPadding = */ false,
163-
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
164-
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
165-
Layout.Alignment.ALIGN_NORMAL,
166-
/* justificationMode = */ 0,
167-
/* ellipsizeMode = */ ellipsizeMode,
168-
/* maxNumberOfLines = */ maxNumberOfLines,
169-
paint,
170-
) as Layout
171-
}
134+
): Layout =
135+
TextLayoutManager.createLayout(
136+
text,
137+
/* boring = */ null,
138+
layoutWidth,
139+
widthMode,
140+
/* includeFontPadding = */ false,
141+
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
142+
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
143+
Layout.Alignment.ALIGN_NORMAL,
144+
/* justificationMode = */ 0,
145+
/* ellipsizeMode = */ ellipsizeMode,
146+
/* maxNumberOfLines = */ maxNumberOfLines,
147+
paint,
148+
)
172149

173150
private fun getBooleanLayoutProperty(layout: Layout, methodName: String): Boolean =
174151
layout.javaClass.getMethod(methodName).invoke(layout) as Boolean

0 commit comments

Comments
 (0)