Skip to content

Commit fa53cb8

Browse files
joevilchesfacebook-github-bot
authored andcommitted
Allow links in facsimile to be keyboard focusable (facebook#51305)
Summary: Pull Request resolved: facebook#51305 tsia, there is a lot of TextView specific API calls and instance checks in the delegate that need to be modified. Additionally, facsimile has some custom focusing logic we do not need if we have a delegate I opted to just do a lot of instance specific logic using `is` . That seems easier for the time being with this text view that should replace our other text view over time. I also expose a new way of focusing a span on facsimile, which may not be the best way to do that, lmk Changelog: [Internal] Reviewed By: NickGerleman Differential Revision: D74104419
1 parent 2fe6c1a commit fa53cb8

File tree

2 files changed

+78
-164
lines changed

2 files changed

+78
-164
lines changed

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

Lines changed: 25 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import android.view.ViewGroup
2222
import androidx.annotation.ColorInt
2323
import androidx.annotation.DoNotInline
2424
import androidx.annotation.RequiresApi
25+
import androidx.core.view.ViewCompat
2526
import com.facebook.react.uimanager.BackgroundStyleApplicator
2627
import com.facebook.react.uimanager.style.Overflow
2728
import kotlin.collections.ArrayList
@@ -122,10 +123,12 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
122123
// No-op
123124
}
124125

125-
private fun setSelection(span: ClickableSpan) {
126+
public fun setSelection(start: Int, end: Int) {
126127
val textLayout = checkNotNull(layout)
127-
val start = (textLayout.text as Spanned).getSpanStart(span)
128-
val end = (textLayout.text as Spanned).getSpanEnd(span)
128+
if (start < 0 || end > textLayout.text.length || start >= end) {
129+
throw IllegalArgumentException(
130+
"setSelection start and end are not in valid range. start: $start, end: $end, text length: ${textLayout.text.length}")
131+
}
129132

130133
val textSelection = selection
131134
if (textSelection == null) {
@@ -141,7 +144,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
141144
invalidate()
142145
}
143146

144-
private fun clearSelection() {
147+
public fun clearSelection() {
145148
selection = null
146149
invalidate()
147150
}
@@ -161,18 +164,21 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
161164
val x = event.x.toInt()
162165
val y = event.y.toInt()
163166

164-
val clickedSpan = getClickableSpanInCoords(x, y)
167+
val clickableSpan = getClickableSpanInCoords(x, y)
165168

166-
if (clickedSpan == null) {
169+
if (clickableSpan == null) {
167170
clearSelection()
168171
return super.onTouchEvent(event)
169172
}
170173

171174
if (action == MotionEvent.ACTION_UP) {
172175
clearSelection()
173-
clickedSpan.onClick(this)
176+
clickableSpan.onClick(this)
174177
} else if (action == MotionEvent.ACTION_DOWN) {
175-
setSelection(clickedSpan)
178+
val textLayout = checkNotNull(layout)
179+
val start = (textLayout.text as Spanned).getSpanStart(clickableSpan)
180+
val end = (textLayout.text as Spanned).getSpanEnd(clickableSpan)
181+
setSelection(start, end)
176182
}
177183

178184
return true
@@ -249,7 +255,6 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
249255
}
250256

251257
public override fun dispatchHoverEvent(event: MotionEvent): Boolean =
252-
// TODO T221698305: Dispatch to AccessibilityDelegate
253258
super.dispatchHoverEvent(event)
254259

255260
public override fun onFocusChanged(
@@ -261,99 +266,20 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
261266
clearSelection()
262267
}
263268
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
264-
// TODO T221698305: Dispatch to AccessibilityDelegate
265-
}
266-
267-
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
268-
// TODO T221698305: Dispatch to AccessibilityDelegate
269-
super.dispatchKeyEvent(event)
270-
271-
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
272-
if (isEnabled &&
273-
clickableSpans.isNotEmpty() &&
274-
selection == null &&
275-
(isDirectionKey(keyCode) || keyCode == KeyEvent.KEYCODE_TAB)) {
276-
// View just received focus due to keyboard navigation. Nothing is currently selected,
277-
// let's select first span according to the navigation direction.
278-
var targetSpan: ClickableSpan? = null
279-
if (isDirectionKey(keyCode) && event.hasNoModifiers()) {
280-
if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
281-
targetSpan = clickableSpans[0]
282-
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_UP) {
283-
targetSpan = clickableSpans[clickableSpans.size - 1]
284-
}
285-
}
286-
287-
if (keyCode == KeyEvent.KEYCODE_TAB) {
288-
if (event.hasNoModifiers()) {
289-
targetSpan = clickableSpans[0]
290-
} else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
291-
targetSpan = clickableSpans[clickableSpans.size - 1]
292-
}
293-
}
294-
295-
if (targetSpan != null) {
296-
setSelection(targetSpan)
297-
return true
298-
}
299-
}
300-
301-
return super.onKeyUp(keyCode, event)
302-
}
303-
304-
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
305-
if (isEnabled &&
306-
clickableSpans.isNotEmpty() &&
307-
(isDirectionKey(keyCode) || isConfirmKey(keyCode)) &&
308-
event.hasNoModifiers()) {
309-
val selectedSpanIndex = selectedSpanIndex()
310-
if (selectedSpanIndex == -1) {
311-
return super.onKeyDown(keyCode, event)
312-
}
313-
314-
if (isDirectionKey(keyCode)) {
315-
val direction =
316-
if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
317-
1
318-
} else {
319-
// keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_UP
320-
-1
321-
}
322-
val repeatCount = 1 + event.repeatCount
323-
val targetIndex = selectedSpanIndex + direction * repeatCount
324-
if (targetIndex >= 0 && targetIndex < clickableSpans.size) {
325-
setSelection(clickableSpans[targetIndex])
326-
return true
327-
}
328-
}
329-
330-
if (isConfirmKey(keyCode) && event.repeatCount == 0) {
331-
clearSelection()
332-
clickableSpans[selectedSpanIndex].onClick(this)
333-
return true
334-
}
269+
val accessibilityDelegateCompat = ViewCompat.getAccessibilityDelegate(this)
270+
if (accessibilityDelegateCompat != null &&
271+
accessibilityDelegateCompat is ReactTextViewAccessibilityDelegate) {
272+
accessibilityDelegateCompat.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
335273
}
336-
337-
return super.onKeyDown(keyCode, event)
338274
}
339275

340-
private fun selectedSpanIndex(): Int {
341-
val spanned = text as? Spanned ?: return -1
342-
val textSelection = selection ?: return -1
276+
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
277+
val accessibilityDelegateCompat = ViewCompat.getAccessibilityDelegate(this)
278+
val delegateHandled =
279+
accessibilityDelegateCompat is ReactTextViewAccessibilityDelegate &&
280+
accessibilityDelegateCompat.dispatchKeyEvent(event)
343281

344-
if (clickableSpans.isEmpty()) {
345-
return -1
346-
}
347-
348-
for (i in clickableSpans.indices) {
349-
val span = clickableSpans[i]
350-
val spanStart = spanned.getSpanStart(span)
351-
val spanEnd = spanned.getSpanEnd(span)
352-
if (spanStart == textSelection.start && spanEnd == textSelection.end) {
353-
return i
354-
}
355-
}
356-
return -1
282+
return delegateHandled || super.dispatchKeyEvent(event)
357283
}
358284

359285
@RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -386,21 +312,8 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context) {
386312
private companion object {
387313
private val selectionPaint = Paint()
388314

389-
private fun isDirectionKey(keyCode: Int): Boolean =
390-
keyCode == KeyEvent.KEYCODE_DPAD_LEFT ||
391-
keyCode == KeyEvent.KEYCODE_DPAD_RIGHT ||
392-
keyCode == KeyEvent.KEYCODE_DPAD_UP ||
393-
keyCode == KeyEvent.KEYCODE_DPAD_DOWN
394-
395-
private fun isConfirmKey(keyCode: Int): Boolean =
396-
keyCode == KeyEvent.KEYCODE_DPAD_CENTER ||
397-
keyCode == KeyEvent.KEYCODE_ENTER ||
398-
keyCode == KeyEvent.KEYCODE_SPACE ||
399-
keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER
400-
401315
private fun filterClickableSpans(text: CharSequence): List<ClickableSpan> {
402-
if (text !is Spanned ||
403-
text.nextSpanTransition(0, text.length, ClickableSpan::class.java) == text.length) {
316+
if (text !is Spanned) {
404317
return emptyList()
405318
}
406319

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

Lines changed: 53 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@
77

88
package com.facebook.react.views.text
99

10-
import android.graphics.Paint
1110
import android.graphics.Rect
1211
import android.os.Bundle
12+
import android.text.Layout
1313
import android.text.Spanned
14-
import android.text.style.AbsoluteSizeSpan
1514
import android.text.style.ClickableSpan
1615
import android.view.View
1716
import android.widget.TextView
@@ -21,7 +20,6 @@ import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
2120
import com.facebook.react.R
2221
import com.facebook.react.uimanager.ReactAccessibilityDelegate
2322
import com.facebook.react.views.text.internal.span.ReactClickableSpan
24-
import kotlin.math.ceil
2523

2624
internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
2725
public constructor(
@@ -76,16 +74,19 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
7674

7775
val link = accessibilityLinks?.getLinkById(virtualViewId) ?: return
7876

79-
val span = getFirstSpan(link.start, link.end, ClickableSpan::class.java)
80-
if (span == null || span !is ReactClickableSpan || hostView !is ReactTextView) {
81-
return
77+
val span = getFirstSpan(link.start, link.end, ClickableSpan::class.java) ?: return
78+
79+
if (span is ReactClickableSpan && hostView is TextView) {
80+
span.isKeyboardFocused = hasFocus
81+
span.focusBgColor = (hostView as TextView).highlightColor
82+
hostView.invalidate()
83+
} else if (hostView is PreparedLayoutTextView) {
84+
if (hasFocus) {
85+
(hostView as PreparedLayoutTextView).setSelection(link.start, link.end)
86+
} else {
87+
(hostView as PreparedLayoutTextView).clearSelection()
88+
}
8289
}
83-
84-
// TODO: When we refactor ReactTextView, implement this using
85-
// https://developer.android.com/reference/android/text/Layout
86-
span.isKeyboardFocused = hasFocus
87-
span.focusBgColor = (hostView as TextView).highlightColor
88-
hostView.invalidate()
8990
}
9091

9192
override fun onPerformActionForVirtualView(
@@ -99,10 +100,7 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
99100

100101
val link = accessibilityLinks?.getLinkById(virtualViewId) ?: return false
101102

102-
val span = getFirstSpan(link.start, link.end, ClickableSpan::class.java)
103-
if (span == null || span !is ReactClickableSpan) {
104-
return false
105-
}
103+
val span = getFirstSpan(link.start, link.end, ClickableSpan::class.java) ?: return false
106104

107105
if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) {
108106
span.onClick(hostView)
@@ -122,49 +120,60 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
122120

123121
override fun getVirtualViewAt(x: Float, y: Float): Int {
124122
val accessibilityLinks = accessibilityLinks ?: return INVALID_ID
125-
if (accessibilityLinks.size() == 0 || hostView !is TextView) {
123+
if (accessibilityLinks.size() == 0 ||
124+
(hostView !is TextView && hostView !is PreparedLayoutTextView)) {
126125
return INVALID_ID
127126
}
128127

129-
var x = x
130-
var y = y
131-
132-
val textView = hostView as TextView
133-
if (textView.text !is Spanned) {
134-
return INVALID_ID
135-
}
136-
137-
val layout = textView.layout ?: return INVALID_ID
138-
139-
x -= textView.totalPaddingLeft.toFloat()
140-
y -= textView.totalPaddingTop.toFloat()
141-
x += textView.scrollX.toFloat()
142-
y += textView.scrollY.toFloat()
128+
var localX = x
129+
var localY = y
130+
localX -= hostView.paddingLeft.toFloat()
131+
localY -= hostView.paddingTop.toFloat()
132+
localX += hostView.scrollX.toFloat()
133+
localY += hostView.scrollY.toFloat()
143134

144-
val line = layout.getLineForVertical(y.toInt())
145-
val charOffset = layout.getOffsetForHorizontal(line, x)
135+
val layout = getLayoutFromHost() ?: return INVALID_ID
136+
val line = layout.getLineForVertical(localY.toInt())
137+
val charOffset = layout.getOffsetForHorizontal(line, localX)
146138

147139
val clickableSpan =
148140
getFirstSpan(charOffset, charOffset, ClickableSpan::class.java) ?: return INVALID_ID
149141

150-
val spanned = textView.text as Spanned
142+
val spanned = getSpannedFromHost() ?: return INVALID_ID
151143
val start = spanned.getSpanStart(clickableSpan)
152144
val end = spanned.getSpanEnd(clickableSpan)
153145

154146
val link: AccessibilityLinks.AccessibleLink? = accessibilityLinks.getLinkBySpanPos(start, end)
155147
return link?.id ?: INVALID_ID
156148
}
157149

158-
protected fun <T> getFirstSpan(start: Int, end: Int, classType: Class<T>?): T? {
159-
if (hostView !is TextView || (hostView as TextView).text !is Spanned) {
160-
return null
150+
private fun getLayoutFromHost(): Layout? {
151+
return if (hostView is PreparedLayoutTextView) {
152+
(hostView as PreparedLayoutTextView).layout
153+
} else if (hostView is TextView) {
154+
(hostView as TextView).layout
155+
} else {
156+
null
161157
}
158+
}
162159

163-
val spanned = (hostView as TextView).text as Spanned
160+
protected fun <T> getFirstSpan(start: Int, end: Int, classType: Class<T>?): T? {
161+
val spanned = getSpannedFromHost() ?: return null
164162
val spans = spanned.getSpans(start, end, classType)
165163
return if (spans.isNotEmpty()) spans[0] else null
166164
}
167165

166+
private fun getSpannedFromHost(): Spanned? {
167+
val host = hostView
168+
return if (host is PreparedLayoutTextView) {
169+
host.layout?.text as? Spanned
170+
} else if (host is TextView) {
171+
host.text as? Spanned
172+
} else {
173+
null
174+
}
175+
}
176+
168177
@Suppress("DEPRECATION")
169178
override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfoCompat) {
170179
// If we get an invalid virtualViewId for some reason (which is known to happen in API 19 and
@@ -203,12 +212,11 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
203212

204213
private fun getBoundsInParent(accessibleLink: AccessibilityLinks.AccessibleLink): Rect? {
205214
// This view is not a text view, so return the entire views bounds.
206-
if (hostView !is TextView) {
215+
if (hostView !is TextView && hostView !is PreparedLayoutTextView) {
207216
return Rect(0, 0, hostView.width, hostView.height)
208217
}
209218

210-
val textView = hostView as TextView
211-
val textViewLayout = textView.layout ?: return Rect(0, 0, textView.width, textView.height)
219+
val textViewLayout = getLayoutFromHost() ?: return Rect(0, 0, hostView.width, hostView.height)
212220

213221
val startOffset = accessibleLink.start
214222
val endOffset = accessibleLink.end
@@ -225,22 +233,15 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
225233

226234
val startXCoordinates = textViewLayout.getPrimaryHorizontal(startOffset).toDouble()
227235

228-
val paint = Paint()
229-
val sizeSpan =
230-
getFirstSpan(accessibleLink.start, accessibleLink.end, AbsoluteSizeSpan::class.java)
231-
val textSize = sizeSpan?.size?.toFloat() ?: textView.textSize
232-
paint.textSize = textSize
233-
val textWidth = ceil(paint.measureText(accessibleLink.description).toDouble()).toInt()
234-
235236
val endOffsetLineNumber = textViewLayout.getLineForOffset(endOffset)
236237
val isMultiline = startOffsetLineNumber != endOffsetLineNumber
237238
textViewLayout.getLineBounds(startOffsetLineNumber, rootRect)
238239

239-
val verticalOffset = textView.scrollY + textView.totalPaddingTop
240+
val verticalOffset = hostView.scrollY + hostView.paddingTop
240241
rootRect.top += verticalOffset
241242
rootRect.bottom += verticalOffset
242243
rootRect.left =
243-
(rootRect.left + (startXCoordinates + textView.totalPaddingLeft - textView.scrollX)).toInt()
244+
(rootRect.left + (startXCoordinates + hostView.paddingLeft - hostView.scrollX)).toInt()
244245

245246
// The bounds for multi-line strings should *only* include the first line. This is because for
246247
// API 25 and below, Talkback's click is triggered at the center point of these bounds, and if
@@ -250,8 +251,8 @@ internal class ReactTextViewAccessibilityDelegate : ReactAccessibilityDelegate {
250251
if (isMultiline) {
251252
return Rect(rootRect.left, rootRect.top, rootRect.right, rootRect.bottom)
252253
}
253-
254-
return Rect(rootRect.left, rootRect.top, rootRect.left + textWidth, rootRect.bottom)
254+
val endXCoordinates = textViewLayout.getPrimaryHorizontal(endOffset).toDouble()
255+
return Rect(rootRect.left, rootRect.top, endXCoordinates.toInt(), rootRect.bottom)
255256
}
256257

257258
override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProviderCompat? {

0 commit comments

Comments
 (0)