fabOnReact / react-native-notes

MIT License
0 stars 0 forks source link

Making links independently focusable by Talkback #9

Closed fabOnReact closed 2 years ago

fabOnReact commented 2 years ago

new branch https://github.com/fabriziobertoglio1987/react-native/tree/independent-links-rebased old branch https://github.com/fabriziobertoglio1987/react-native/tree/independent-links https://github.com/facebook/react-native/commit/b352e2da8137452f66717cf1cecb2e72abd727d7

Right now nested Text components are not accessible on Android. This is because we only create a native ReactTextView for the parent component; the styling and touch handling for the child component are handled using spans. In order for TalkBack to announce the link, we need to linkify the text using a ClickableSpan. This diff adds ReactClickableSpan, which TextLayoutManager uses to linkify a span of text when its corresponding React component has accessibilityRole="link". For example:

  <Text>
    A paragraph with some
    <Text accessible={true} accessibilityRole="link" onPress={onPress} onClick={onClick}>links</Text>
    surrounded by other text.
  </Text>

With this diff, the child Text component will be announced by TalkBack ('links available') and exposed as an option in the context menu. Clicking on the link in the context menu fires the Text component's onClick, which we're explicitly forwarding to onPress in Text.js (for now - ideally this would probably use a separate event, but that would involve wiring it up in the renderer as well).

https://github.com/facebook/react-native/pull/31757

Summary: This follows up on D23553222 (https://github.com/facebook/react-native/commit/b352e2da8137452f66717cf1cecb2e72abd727d7), which made links functional by using Talkback's Links menu. We don't often use this as the sole access point for links due to it being more difficult for users to navigate to, and easy for users to miss if they don't listen to the entire description, including the hint text that announces that links are available.

The PR allows TalkBack to move the focus directly on the nested Text link (accessibilityRole="link"). The nested link becomes the next focusable element after the that contains it. If there are multiple links within a single text element, they will each be focusable in order after the main element.

Related https://github.com/facebook/react-native/issues/30375 https://developer.android.com/reference/android/text/style/ClickableSpan https://developer.android.com/reference/androidx/core/view/ViewCompat#enableAccessibleClickableSpanSupport(android.view.View) https://stackoverflow.com/a/62222068/7295772 https://github.com/facebook/react-native/issues/32004

fabOnReact commented 2 years ago

The nested link becomes the next focusable element after the parent element that contains it.

Talkback moves focus on a <Text> element that has several nested <Text accessibilityRole=“link”> elements

Related https://github.com/facebook/react-native/pull/31757 https://github.com/facebook/react-native/commit/b352e2da8137452f66717cf1cecb2e72abd727d7

Expected Result:

CLICK TO OPEN SOURCECODE

https://github.com/fabriziobertoglio1987/react-native-notes/blob/de66d8064361246ddb7ce0b97d4d2d8cfcfcc172/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js#L177-L215

CLICK TO OPEN VIDEO TESTS - pr branch

CLICK TO OPEN VIDEO TESTS - main branch

fabOnReact commented 2 years ago

User Interacts with links through TalkBack default accessibility menu

Functionality built with commit https://github.com/facebook/react-native/commit/b352e2da8137452f66717cf1cecb2e72abd727d7

Expected Result: The user can interact with the nested link through the TalkBack accessibility menu.

CLICK TO OPEN SOURCECODE

https://github.com/fabriziobertoglio1987/react-native-notes/blob/de66d8064361246ddb7ce0b97d4d2d8cfcfcc172/packages/rn-tester/js/examples/Accessibility/AccessibilityAndroidExample.android.js#L177-L215

CLICK TO OPEN VIDEO TESTS - pr branch

CLICK TO OPEN VIDEO TESTS - main branch - link accessible through menu

fabOnReact commented 2 years ago
fabOnReact commented 2 years ago
ClickableSpan vs AccessibileClickableSpan

https://github.com/facebook/react-native/issues/30375#issuecomment-830781591

git diff main..independent-links **/ReactBaseTextShadowNode.java

![image](https://user-images.githubusercontent.com/24992535/154790238-50ee94d1-29cc-41dc-8efb-2b070923902d.png)

https://developer.android.com/reference/android/text/style/ClickableSpan https://stackoverflow.com/a/62332707/7295772 ```java public void onAccessibilityEvent(AccessibilityEvent) { AccessibilityNodeInfo nodeInfo = event.getSource(); if (nodeInfo != null) { List nodeInfoList = nodeInfo.findAccessibilityNodeInfosByText( "This app wants to access your location all the time, even when you're not using the app. Allow in settings."); if (nodeInfoList != null && !nodeInfoList.isEmpty()) { ClickableSpan[] spans = getClickableSpans(nodeInfoList.get(0).getText()); if (spans.length > 0) { spans[0].onClick(null); } } } } ```
getClickableSpan

```java @NonNull ClickableSpan[] getClickableSpans(CharSequence text) { try { if (text instanceof Spanned) { Spanned spanned = (Spanned) text; return spanned.getSpans(0, text.length(), ClickableSpan.class); } } catch (Exception e) { //log exception } return new ClickableSpan[0]; } ```

>A hidden class was added called `AccessibilityClickableSpan` that implements `ClickableSpan` and you can see that its implementation of `onClick` does indeed ignore the `View` parameter, as it would seemingly have to in this case. So while I still don't see any documentation of this, it seems like an implementation detail that can't reasonably change in the future. A ClickableSpan from an AccessibilityNodeInfo should continue to support passing `null`. https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/text/style/AccessibilityClickableSpan.java#L35-L45 ```java /** * Perform the click from an accessibility service. Will not work unless * setAccessibilityNodeInfo is called with a properly initialized node. * * @param unused This argument is required by the superclass but is unused. The real view will * be determined by the AccessibilityNodeInfo. */ @Override public void onClick(View unused) { Bundle arguments = new Bundle(); arguments.putParcelable(ACTION_ARGUMENT_ACCESSIBLE_CLICKABLE_SPAN, this); if ((mWindowId == UNDEFINED_WINDOW_ID) || (mSourceNodeId == UNDEFINED_NODE_ID) || (mConnectionId == UNDEFINED_CONNECTION_ID)) { throw new RuntimeException( "ClickableSpan for accessibility service not properly initialized"); } AccessibilityInteractionClient client = AccessibilityInteractionClient.getInstance(); client.performAccessibilityAction(mConnectionId, mWindowId, mSourceNodeId, R.id.accessibilityActionClickOnClickableSpan, arguments); } ```

fabOnReact commented 2 years ago
enableAccessibleClickableSpanSupport

https://stackoverflow.com/a/62222068/7295772 https://android.googlesource.com/platform/frameworks/support/+/f7528a970ad7f2f099b4dc1397084cc388c9193c/core/src/main/java/androidx/core/view/ViewCompat.java#1316 ```java /** * Allow accessibility services to find and activate clickable spans in the application. * * @param view The view *

* Compatibility: *

    *
  • API < 19: No-op *
*/ public static void enableAccessibleClickableSpanSupport(View view) { if (Build.VERSION.SDK_INT >= 19) { getOrCreateAccessibilityDelegateCompat(view); } } ``` https://github.com/androidx/androidx/blob/150112ca4e1e670fb4eb6756993f3a25df0a5da9/core/core/src/main/java/androidx/core/view/ViewCompat.java#L931-L939 ```java static AccessibilityDelegateCompat getOrCreateAccessibilityDelegateCompat( @NonNull View v) { AccessibilityDelegateCompat delegateCompat = getAccessibilityDelegate(v); if (delegateCompat == null) { delegateCompat = new AccessibilityDelegateCompat(); } setAccessibilityDelegate(v, delegateCompat); return delegateCompat; } ```

fabOnReact commented 2 years ago
fabOnReact commented 2 years ago
AccessibilityClickableSpan

https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/text/style/AccessibilityClickableSpan.java#L36 ```java /** * {@link ClickableSpan} cannot be parceled, but accessibility services need to be able to cause * their callback handlers to be called. This class serves as a parcelable placeholder for the * real spans. * * This span is also passed back to an app's process when an accessibility service tries to click * it. It contains enough information to track down the original clickable span so it can be * called. * * @hide */ public class AccessibilityClickableSpan extends ClickableSpan ``` https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/os/Parcel.java#L61 ```java /** * Container for a message (data and object references) that can * be sent through an IBinder. A Parcel can contain both flattened data * that will be unflattened on the other side of the IPC (using the various * methods here for writing specific types, or the general * {@link Parcelable} interface), and references to live {@link IBinder} * objects that will result in the other side receiving a proxy IBinder * connected with the original IBinder in the Parcel. *

Parcelables

* *

The {@link Parcelable} protocol provides an extremely efficient (but * low-level) protocol for objects to write and read themselves from Parcels. * You can use the direct methods {@link #writeParcelable(Parcelable, int)} * and {@link #readParcelable(ClassLoader)} or * {@link #writeParcelableArray} and * {@link #readParcelableArray(ClassLoader)} to write or read. ``` https://en.wikipedia.org/wiki/Inter-process_communication >In [computer science](https://en.wikipedia.org/wiki/Computer_science), inter-process communication or interprocess communication (IPC) refers specifically to the mechanisms an [operating system](https://en.wikipedia.org/wiki/Operating_system) provides to allow the [processes](https://en.wikipedia.org/wiki/Process_(computing)) to manage shared data. Typically, applications can use IPC, categorized as [clients and servers](https://en.wikipedia.org/wiki/Client%E2%80%93server_model), where the client requests data and the server responds to client requests.[[1]](https://en.wikipedia.org/wiki/Inter-process_communication#cite_note-microsoft.com-1) Many applications are both clients and servers, as commonly seen in [distributed computing](https://en.wikipedia.org/wiki/Distributed_computing).

fabOnReact commented 2 years ago
getVirtualViewAt

https://github.com/fabriziobertoglio1987/react-native-notes/blob/de66d8064361246ddb7ce0b97d4d2d8cfcfcc172/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java#L483-L520 https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/com/android/internal/widget/ExploreByTouchHelper.java#L32-L48 https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/com/android/internal/widget/ExploreByTouchHelper.java#L653-L662 https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/widget/SimpleMonthView.java#L1050-L1071 https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/widget/RadialTimePickerView.java#L1149-L1181

onPopulateBoundsForVirtualView

https://github.com/fabriziobertoglio1987/react-native-notes/blob/de66d8064361246ddb7ce0b97d4d2d8cfcfcc172/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java#L534-L549

fabOnReact commented 2 years ago
Done

- [x] change values in [getVirtualViewAt](https://github.com/fabriziobertoglio1987/react-native-notes/blob/de66d8064361246ddb7ce0b97d4d2d8cfcfcc172/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java#L484) and verify changes in TalkBack - [x] Try to implement AccessibilityClickableSpan on Android App and test it with TalkBack - [x] Implement logic in a separate AccessibilityDelegate attached to TextView - [x] debug [this methods](https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1046384200) and see what is not working in main branch - [x] import AccessibilityRole from ReactAccessibilityDelegate - [x] try to delete logic not added with `g d ..blavalla/export-D28691177 **/ReactAccessibilityDelegate.java` from `ReactTextAccessibilityDelegate` - [x] comment accessibilityAction logic and test if still works in both Text and nested Text - [x] comment accessibilityState logic => [does not announce disabled](https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1046673529) but is disabled - [x] check diff with master, comment logic and verify is required for the functionality - [x] Remove logic for ReactTextAccessibilityDelegate - [x] check accessibilityAction both on Text, View, Pressable on main and branch - [x] change the length of the AccessibilityClickableSpan and test TalkBack focus - [x] verify differences between approach in independent-links and [getClickableSpan](https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1045918241) - [x] call `setFocusable` - [x] Text actions don't work - [x] Text does not announce disabled - [x] further test accessibility functionalities in Text, Nested Text, View, Button, TouchableOpacity - [x] scrolling down with TalkBack sometimes trigger [no next link](https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1047480800) - [x] ReactAccessibilityDelegate extends ExploreByTouch and ReactTextDelegate extends ReactAccessibilityDelegate - [x] prepare questions on what you should improve

Low Priority

- [ ] improve functionality to span text over one line - [ ] [Nested text still does not announce disabled](https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1046673529), so you may look into their respective ViewManager and Views - [ ] Move shared logic to a new class - [ ] Try to use default constructor instead of the one with 3 params - [ ] Method setAccessibilityDelegate should be moved to common parent - [ ] Follow same pattern used with ReactSliderAccessibilityDelegate - [ ] review diff between independent-links and main - [ ] save class instance in constructor `mSharedClassInstance` - [ ] use shared logic `mSharedClassInstance.sharedMethod()` (composition) - [ ] Use [multiple inheritance ](https://stackoverflow.com/a/21824485/7295772)in ReactTextAccessibility Delegate (extends ExploreByTouchHelper and ReactAccessibilityDelegate) - [ ] Share interface (in ruby a module) between ReactAccessibilityDelegate and ReactTextAccessibilityDelegate

Deleted

- [ ] change constructor to take 1 parameter

fabOnReact commented 2 years ago
The TalkBack focus (green rectangle) does not span on multiple lines

CLICK TO OPEN SOURCECODE

```javascript { alert('pressed long link'); }}> link that spans multiple lines because the text is so long. ```

CLICK TO OPEN VIDEO TESTS

fabOnReact commented 2 years ago
isVirtual

https://github.com/fabriziobertoglio1987/react-native-notes/blob/de66d8064361246ddb7ce0b97d4d2d8cfcfcc172/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java#L499-L505 https://github.com/fabriziobertoglio1987/react-native-notes/blob/de66d8064361246ddb7ce0b97d4d2d8cfcfcc172/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNode.java#L24-L54

fabOnReact commented 2 years ago
CustomClickableSpan is clickable without talkback

Related https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1045918241

CLICK TO OPEN SOURCECODE

MainActivity.java ```java public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); LinearLayout linearLayout = (LinearLayout) findViewById(R.id.linearlayout); TextView textView = new TextView(this); textView.setMovementMethod(LinkMovementMethod.getInstance()); SpannableStringBuilder spannable = new SpannableStringBuilder("Text is spantastic!"); spannable.setSpan( new CustomClickableSpan(Color.RED), 2, // start 12, // end Spannable.SPAN_EXCLUSIVE_INCLUSIVE ); textView.setText(spannable); linearLayout.addView(textView); } } ``` CustomClickabeSpan.java ```java public class CustomClickableSpan extends ClickableSpan { private int color; public CustomClickableSpan(int spanColor) { super(); color = spanColor; } @Override public void onClick(@NonNull View widget) { Log.w("TESTING::", "onClick called on view: " + widget); } @Override public void updateDrawState(@NonNull TextPaint ds) { super.updateDrawState(ds); ds.setColor(color); } } ```

CLICK TO OPEN VIDEO TESTS

fabOnReact commented 2 years ago
Nested Links do not announce disabled or other accessibility properties

**Expected Result**: - Announces disabled. - is disabled **Actual Result**: - Does **not** announce disabled. - is disabled

CLICK TO OPEN SOURCECODE

```javascript <> This is a{' '} { alert('pressed test'); }}> test {' '} of{' '} { alert('pressed Inline Links'); }}> Inline Links in React Native. Here's{' '} { alert('pressed another link'); }}> another link . Here is a{' '} { alert('pressed long link'); }}> link that spans multiple lines because the text is so long. I wonder how this works? Normal Text

CLICK TO OPEN NOTES

Here the accessibility links are created https://github.com/fabriziobertoglio1987/react-native-notes/blob/052efec4b4c94dd5cf7a61ef6fc6043a5129884e/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java#L77-L82 https://github.com/fabriziobertoglio1987/react-native-notes/blob/052efec4b4c94dd5cf7a61ef6fc6043a5129884e/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactTextAccessibilityDelegate.java#L469-L501 The accessibility node information are set here https://github.com/fabriziobertoglio1987/react-native-notes/blob/052efec4b4c94dd5cf7a61ef6fc6043a5129884e/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactTextAccessibilityDelegate.java#L366-L372

CLICK TO OPEN VIDEO TESTS

fabOnReact commented 2 years ago
TalkBack announces no next link when trying to move focus to next element.

When reaching the last page and trying to further scroll down in the ScrollView. **Expected Result**: TalkBack does not announce. **Actual Result**: TalkBack announces no next link.

CLICK TO OPEN SOURCECODE

```javascript <> Alert.alert('Disabled Button has been pressed!')} accessibilityLabel={'You are pressing Disabled TouchableOpacity'} accessibilityState={{disabled: true}}> I am disabled. Clicking me will not trigger any action. This view is selected and disabled. ```

CLICK TO OPEN VIDEO TESTS - BRANCH

CLICK TO OPEN VIDEO TESTS - MAIN

fabOnReact commented 2 years ago
Done

- [x] Review Meeting Notes when B. talks about tests - [x] Review comment on [slack](https://reactnativeac-vaz6944.slack.com/archives/C032SJNL35G/p1645142532114889) - [x] Understand if rn-tester tests are updated. Check rn-tester github history. - [x] Evaluate if building rn-tester with Fabric - [x] Evaluate if building rn-tester with Paper - [x] Test on Paper - [x] Test on Fabric - [x] Test for potential regressions in Fabric - [x] search for android method that provide this functionalities (for ex. retrieve all the links, instead of writing the logic we can find method in Android API and use it to retrieve the spans)

fabOnReact commented 2 years ago

Testing accessibility examples in main branch

CLICK TO OPEN SOURCECODE

```javascript /** * 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. * * @format * @flow */ 'use strict'; import type {PressEvent} from 'react-native/Libraries/Types/CoreEventTypes'; const React = require('react'); const { AccessibilityInfo, TextInput, Button, Image, Text, View, TouchableOpacity, TouchableWithoutFeedback, Alert, StyleSheet, Slider, Platform, } = require('react-native'); import type {EventSubscription} from 'react-native/Libraries/vendor/emitter/EventEmitter'; const RNTesterBlock = require('../../components/RNTesterBlock'); const checkImageSource = require('./check.png'); const uncheckImageSource = require('./uncheck.png'); const mixedCheckboxImageSource = require('./mixed.png'); const {createRef} = require('react'); const styles = StyleSheet.create({ default: { borderWidth: StyleSheet.hairlineWidth, borderColor: '#0f0f0f', flex: 1, fontSize: 13, padding: 4, }, touchable: { backgroundColor: 'blue', borderColor: 'red', borderWidth: 1, borderRadius: 10, padding: 10, borderStyle: 'solid', }, image: { width: 20, height: 20, resizeMode: 'contain', marginRight: 10, }, disabledImage: { width: 120, height: 120, }, containerAlignCenter: { display: 'flex', flexDirection: 'column', justifyContent: 'space-between', }, }); class AccessibilityExample extends React.Component<{}> { render(): React.Node { return ( Text's accessibilityLabel is the raw text itself unless it is set explicitly. This text component's accessibilityLabel is set explicitly. This is text one. This is text two. This is text one. This is text two. This is text one. This is text two. {/* Android screen readers will say the accessibility hint instead of the text since the view doesn't have a label. */} This is text one. This is text two. This is text one. This is text two. This is a title. Alert.alert('Link has been clicked!')} accessibilityRole="link"> Click me Alert.alert('Button has been pressed!')} accessibilityRole="button"> Click me Alert.alert('Button has been pressed!')} accessibilityRole="button" accessibilityState={{disabled: true}} disabled={true}> I am disabled. Clicking me will not trigger any action. Alert.alert('Disabled Button has been pressed!')} accessibilityLabel={'You are pressing Disabled TouchableOpacity'} accessibilityState={{disabled: true}}> I am disabled. Clicking me will not trigger any action. This view is selected and disabled. Accessible view with label, hint, role, and state Mail Address First Name ); } } class CheckboxExample extends React.Component< {}, { checkboxState: boolean | 'mixed', }, > { state = { checkboxState: true, }; _onCheckboxPress = () => { let checkboxState = false; if (this.state.checkboxState === false) { checkboxState = 'mixed'; } else if (this.state.checkboxState === 'mixed') { checkboxState = true; } else { checkboxState = false; } this.setState({ checkboxState: checkboxState, }); }; render() { return ( Checkbox example ); } } class SwitchExample extends React.Component< {}, { switchState: boolean, }, > { state = { switchState: true, }; _onSwitchToggle = () => { const switchState = !this.state.switchState; this.setState({ switchState: switchState, }); }; render() { return ( Switch example ); } } class SelectionExample extends React.Component< {}, { isSelected: boolean, isEnabled: boolean, }, > { constructor(props: {}) { super(props); this.selectableElement = createRef(); } selectableElement: { current: React.ElementRef | null, }; state = { isSelected: true, isEnabled: false, }; render(): React.Node { const {isSelected, isEnabled} = this.state; let accessibilityHint = 'click me to select'; if (isSelected) { accessibilityHint = 'click me to unselect'; } if (!isEnabled) { accessibilityHint = 'use the button on the right to enable selection'; } let buttonTitle = isEnabled ? 'Disable selection' : 'Enable selection'; const touchableHint = ` (touching the TouchableOpacity will ${ isSelected ? 'disable' : 'enable' } accessibilityState.selected)`; return ( { if (isEnabled) { this.setState({ isSelected: !isSelected, }); } else { console.warn('selection is disabled, please enable selection.'); } }} accessibilityLabel="element 19" accessibilityState={{ selected: isSelected, disabled: !isEnabled, }} style={styles.touchable} accessibilityHint={accessibilityHint}> {`Selectable TouchableOpacity Example ${touchableHint}`}

fabOnReact commented 2 years ago

Testing accessibility android examples in main branch

CLICK TO OPEN SOURCECODE

```javascript /** * 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. * * @format * @flow strict-local */ 'use strict'; const React = require('react'); import RNTesterBlock from '../../components/RNTesterBlock'; import RNTesterPage from '../../components/RNTesterPage'; import {StyleSheet, Text, View, TouchableWithoutFeedback} from 'react-native'; const importantForAccessibilityValues = [ 'auto', 'yes', 'no', 'no-hide-descendants', ]; type AccessibilityAndroidExampleState = { count: number, backgroundImportantForAcc: number, forgroundImportantForAcc: number, }; class AccessibilityAndroidExample extends React.Component< {}, AccessibilityAndroidExampleState, > { state: AccessibilityAndroidExampleState = { count: 0, backgroundImportantForAcc: 0, forgroundImportantForAcc: 0, }; _addOne = () => { this.setState({ count: ++this.state.count, }); }; _changeBackgroundImportantForAcc = () => { this.setState({ backgroundImportantForAcc: (this.state.backgroundImportantForAcc + 1) % 4, }); }; _changeForgroundImportantForAcc = () => { this.setState({ forgroundImportantForAcc: (this.state.forgroundImportantForAcc + 1) % 4, }); }; render(): React.Node { return ( Click me Clicked {this.state.count} times Hello world Change importantForAccessibility for background layout. Background layout importantForAccessibility { importantForAccessibilityValues[ this.state.backgroundImportantForAcc ] } Change importantForAccessibility for forground layout. Forground layout importantForAccessibility { importantForAccessibilityValues[ this.state.forgroundImportantForAcc ] } ); } } const styles = StyleSheet.create({ touchableContainer: { position: 'absolute', left: 10, top: 10, right: 10, height: 100, backgroundColor: 'green', }, embedded: { backgroundColor: 'yellow', padding: 10, }, container: { flex: 1, backgroundColor: 'white', padding: 10, height: 150, }, }); exports.title = 'AccessibilityAndroid'; exports.description = 'Android specific Accessibility APIs.'; exports.examples = [ { title: 'Accessibility elements', render(): React.Element { return ; }, }, ]; ```

fabOnReact commented 2 years ago

Testing accessibility examples in pr branch

CLICK TO OPEN SOURCECODE

```javascript /** * 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. * * @format * @flow */ 'use strict'; import type {PressEvent} from 'react-native/Libraries/Types/CoreEventTypes'; const React = require('react'); const { AccessibilityInfo, TextInput, Button, Image, Text, View, TouchableOpacity, TouchableWithoutFeedback, Alert, StyleSheet, Slider, Platform, } = require('react-native'); import type {EventSubscription} from 'react-native/Libraries/vendor/emitter/EventEmitter'; const RNTesterBlock = require('../../components/RNTesterBlock'); const checkImageSource = require('./check.png'); const uncheckImageSource = require('./uncheck.png'); const mixedCheckboxImageSource = require('./mixed.png'); const {createRef} = require('react'); const styles = StyleSheet.create({ default: { borderWidth: StyleSheet.hairlineWidth, borderColor: '#0f0f0f', flex: 1, fontSize: 13, padding: 4, }, touchable: { backgroundColor: 'blue', borderColor: 'red', borderWidth: 1, borderRadius: 10, padding: 10, borderStyle: 'solid', }, image: { width: 20, height: 20, resizeMode: 'contain', marginRight: 10, }, disabledImage: { width: 120, height: 120, }, containerAlignCenter: { display: 'flex', flexDirection: 'column', justifyContent: 'space-between', }, }); class AccessibilityExample extends React.Component<{}> { render(): React.Node { return ( Text's accessibilityLabel is the raw text itself unless it is set explicitly. This text component's accessibilityLabel is set explicitly. This is text one. This is text two. This is text one. This is text two. This is text one. This is text two. {/* Android screen readers will say the accessibility hint instead of the text since the view doesn't have a label. */} This is text one. This is text two. This is text one. This is text two. This is a title. Alert.alert('Link has been clicked!')} accessibilityRole="link"> Click me Alert.alert('Button has been pressed!')} accessibilityRole="button"> Click me Alert.alert('Button has been pressed!')} accessibilityRole="button" accessibilityState={{disabled: true}} disabled={true}> I am disabled. Clicking me will not trigger any action. Alert.alert('Disabled Button has been pressed!')} accessibilityLabel={'You are pressing Disabled TouchableOpacity'} accessibilityState={{disabled: true}}> I am disabled. Clicking me will not trigger any action. This view is selected and disabled. Accessible view with label, hint, role, and state Mail Address First Name ); } } class CheckboxExample extends React.Component< {}, { checkboxState: boolean | 'mixed', }, > { state = { checkboxState: true, }; _onCheckboxPress = () => { let checkboxState = false; if (this.state.checkboxState === false) { checkboxState = 'mixed'; } else if (this.state.checkboxState === 'mixed') { checkboxState = true; } else { checkboxState = false; } this.setState({ checkboxState: checkboxState, }); }; render() { return ( Checkbox example ); } } class SwitchExample extends React.Component< {}, { switchState: boolean, }, > { state = { switchState: true, }; _onSwitchToggle = () => { const switchState = !this.state.switchState; this.setState({ switchState: switchState, }); }; render() { return ( Switch example ); } } class SelectionExample extends React.Component< {}, { isSelected: boolean, isEnabled: boolean, }, > { constructor(props: {}) { super(props); this.selectableElement = createRef(); } selectableElement: { current: React.ElementRef | null, }; state = { isSelected: true, isEnabled: false, }; render(): React.Node { const {isSelected, isEnabled} = this.state; let accessibilityHint = 'click me to select'; if (isSelected) { accessibilityHint = 'click me to unselect'; } if (!isEnabled) { accessibilityHint = 'use the button on the right to enable selection'; } let buttonTitle = isEnabled ? 'Disable selection' : 'Enable selection'; const touchableHint = ` (touching the TouchableOpacity will ${ isSelected ? 'disable' : 'enable' } accessibilityState.selected)`; return ( { if (isEnabled) { this.setState({ isSelected: !isSelected, }); } else { console.warn('selection is disabled, please enable selection.'); } }} accessibilityLabel="element 19" accessibilityState={{ selected: isSelected, disabled: !isEnabled, }} style={styles.touchable} accessibilityHint={accessibilityHint}> {`Selectable TouchableOpacity Example ${touchableHint}`}

fabOnReact commented 2 years ago

Testing accessibility android examples in pr branch

CLICK TO OPEN SOURCECODE

```javascript /** * 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. * * @format * @flow strict-local */ 'use strict'; const React = require('react'); import RNTesterBlock from '../../components/RNTesterBlock'; import RNTesterPage from '../../components/RNTesterPage'; import {StyleSheet, Text, View, TouchableWithoutFeedback} from 'react-native'; const importantForAccessibilityValues = [ 'auto', 'yes', 'no', 'no-hide-descendants', ]; type AccessibilityAndroidExampleState = { count: number, backgroundImportantForAcc: number, forgroundImportantForAcc: number, }; class AccessibilityAndroidExample extends React.Component< {}, AccessibilityAndroidExampleState, > { state: AccessibilityAndroidExampleState = { count: 0, backgroundImportantForAcc: 0, forgroundImportantForAcc: 0, }; _addOne = () => { this.setState({ count: ++this.state.count, }); }; _changeBackgroundImportantForAcc = () => { this.setState({ backgroundImportantForAcc: (this.state.backgroundImportantForAcc + 1) % 4, }); }; _changeForgroundImportantForAcc = () => { this.setState({ forgroundImportantForAcc: (this.state.forgroundImportantForAcc + 1) % 4, }); }; render(): React.Node { return ( Click me Clicked {this.state.count} times Hello world Change importantForAccessibility for background layout. Background layout importantForAccessibility { importantForAccessibilityValues[ this.state.backgroundImportantForAcc ] } Change importantForAccessibility for forground layout. Forground layout importantForAccessibility { importantForAccessibilityValues[ this.state.forgroundImportantForAcc ] } ); } } const styles = StyleSheet.create({ touchableContainer: { position: 'absolute', left: 10, top: 10, right: 10, height: 100, backgroundColor: 'green', }, embedded: { backgroundColor: 'yellow', padding: 10, }, container: { flex: 1, backgroundColor: 'white', padding: 10, height: 150, }, }); exports.title = 'AccessibilityAndroid'; exports.description = 'Android specific Accessibility APIs.'; exports.examples = [ { title: 'Accessibility elements', render(): React.Element { return ; }, }, ]; ```

CLICK TO OPEN VIDEO TESTS PART 1

CLICK TO OPEN VIDEO TESTS PART 2 - LINKS

CLICK TO OPEN VIDEO TESTS PART 3 - LINKS

>They should be focused in order from top to bottom *after* thecontents of the entire paragraph.

fabOnReact commented 2 years ago
TextView onInitializeAccessibilityNodeInfoInternal

https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/widget/TextView.java#L11866-L12090 https://github.com/androidx/androidx/blob/150112ca4e1e670fb4eb6756993f3a25df0a5da9/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeInfoCompat.java#L2695-L2719

fabOnReact commented 2 years ago
LinkMovementMethod

https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/widget/TextView.java#L11956-L11967 ```java // A view should not be exposed as clickable/long-clickable to a service because of a // LinkMovementMethod. if ((info.isClickable() || info.isLongClickable()) && mMovement instanceof LinkMovementMethod) { if (!hasOnClickListeners()) { info.setClickable(false); info.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK); } if (!hasOnLongClickListeners()) { info.setLongClickable(false); info.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK); } } ```

fabOnReact commented 2 years ago
AccessibilityNodeInfo#setText

https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/view/accessibility/AccessibilityNodeInfo.java#L2852-L2876 ```java /** * Sets the text of this node. *

* Note: Cannot be called from an * {@link android.accessibilityservice.AccessibilityService}. * This class is made immutable before being delivered to an AccessibilityService. *

* * @param text The text. * * @throws IllegalStateException If called from an AccessibilityService. */ public void setText(CharSequence text) { enforceNotSealed(); mOriginalText = text; if (text instanceof Spanned) { CharSequence tmpText = text; tmpText = replaceClickableSpan(tmpText); tmpText = replaceReplacementSpan(tmpText); mText = tmpText; return; } mText = (text == null) ? null : text.subSequence(0, text.length()); } ```

fabOnReact commented 2 years ago
AccessibilityNodeInfo#getText

https://github.com/androidx/androidx/blob/150112ca4e1e670fb4eb6756993f3a25df0a5da9/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeInfoCompat.java#L2660-L2682 ```java /** * Gets the text of this node. * * @return The text. */ public CharSequence getText() { if (hasSpans()) { List starts = extrasIntList(SPANS_START_KEY); List ends = extrasIntList(SPANS_END_KEY); List flags = extrasIntList(SPANS_FLAGS_KEY); List ids = extrasIntList(SPANS_ID_KEY); Spannable spannable = new SpannableString(TextUtils.substring(mInfo.getText(), 0, mInfo.getText().length())); for (int i = 0; i < starts.size(); i++) { spannable.setSpan(new AccessibilityClickableSpanCompat(ids.get(i), this, getExtras().getInt(SPANS_ACTION_ID_KEY)), starts.get(i), ends.get(i), flags.get(i)); } return spannable; } else { return mInfo.getText(); } } ```

Notes 1st of March

The method returns an instance of Spannable which uses AccessibilityClickableSpanCompat https://github.com/androidx/androidx/blob/150112ca4e1e670fb4eb6756993f3a25df0a5da9/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityClickableSpanCompat.java#L54-L59 debug the value returned in `onInitializeAccessibilityNodeInfo` `info.getText()` https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/text/Spanned.java#L170-L198

fabOnReact commented 2 years ago
Done

- [x] [AccessibilityLink][4] is duplicate of [SetSpanOperation][3] (used [here][5]) - [x] try to revert this changes to [ReactTextView][6] - [x] read android sourcecode and avoid code duplication (for ex. [getClickableSpans][7]) - [x] [consider moving the logic to AccessibilityNodeInfo callbacks][12] (spans are already managed in [nodeInfo][8]) - [x] add back [accessibility_links][11] tag commit to revert https://github.com/fabriziobertoglio1987/react-native/commit/d35e79f3df3c7e4ee9414e33a28752c4e9dd6e6d - [x] adding [mAccessibilityLinks][9] to [nodeInfo][10] - [x] remove duplicate logic from ReactTextAccessibilityDelegate - [x] [retrieve SPANS_START_KEY and SPANS_END_KEY from nodeInfo][1] - [x] check buck [dependency][2] uimanager - [x] review diff

TODOs

fabOnReact commented 2 years ago
retrieve SPANS_START_KEY and SPANS_END_KEY from nodeInfo

Seems that spans variables area already saved after calling setText - try to create setter to read variables - log variables inside one of the NodeInfo callbacks https://github.com/androidx/androidx/blob/150112ca4e1e670fb4eb6756993f3a25df0a5da9/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeInfoCompat.java#L2667 ```java List starts = extrasIntList(SPANS_START_KEY); ``` https://github.com/androidx/androidx/blob/150112ca4e1e670fb4eb6756993f3a25df0a5da9/core/core/src/main/java/androidx/core/view/accessibility/AccessibilityNodeInfoCompat.java#L2676 ```java starts.get(i), ends.get(i), flags.get(i)); ``` seems to be similar logic implemented in AccessibilityLinks

AccessibilityLinks constructor

https://github.com/fabriziobertoglio1987/react-native-notes/blob/4368f3a8ae3b5c11ffb2b5eb947f5a5aabb62524/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java#L616-L640

fabOnReact commented 2 years ago
consider moving the logic to AccessibilityNodeInfo callbacks

Notes

>Should I follow the same pattern used with the Android Widgets TextView, GridView, ScrollView which consist of: >1) an AccessibilityDelegate class with the logic shared between all the widgets >2) Each Widget (ScrollView, GridView) over-rides the AccessibilityDelegate logic with the methods onInitializeAccessibilityEvent or NodeInfoInternal >- AccessibilityNodeInfo method set/getText() allows us to retrieve the Text spans >- AccessibilityLinks returns from the spans the information of the links start, end, text and position >- ExploreByTouchHelper includes methods like onPopulateNodeForVirtualView etc.. to implement the functionality of navigating links with TalkBack

ReactTextView over-ride of onAccessibilityNodeInfoInternal method as done in [AOSP]( https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1055119178) - [nodeInfo.getText()](https://github.com/fabriziobertoglio1987/react-native-notes/issues/9#issuecomment-1056039939) to get the text as done in https://github.com/fabriziobertoglio1987/react-native/blob/7d4f25735728f5d86ad472bec0b2e8af9c88127e/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java#L344 - save the links in nodeInfo or instance variable of AccessibilityDelegate ReactTextAccesibilityDelegate as done with the ReactSliderAccessibilityDelegate - needs to get the links from nodeInfo - use the links in methods onPopulateNodeForVirtualView, getVisibleVirtualViews, setDelegate...

fabOnReact commented 2 years ago

Summary

This issue fixes https://github.com/facebook/react-native/issues/32004. The Pull Request was previously published by blavalla with https://github.com/facebook/react-native/pull/31757.

This is a follow-up on D23553222, which made links functional by using Talkback's Links menu. We don't often use this as the sole access point for links due to it being more difficult for users to navigate to and easy for users to miss if they don't listen to the full description, including the hint text that announces that links are available.

Retrieving accessibility links and triggering TalkBack Focus over the Text

  1. Using ReactClickableSpan for nested Texts components with accessibilityRole link
  2. Retrieving the above Spans (nested Text components)
  3. Obtain each link description, start, end and position in the parent Text (id) from the Span as an AccessibilityLink
  4. Use the AccessibilityLink to display TalkBack focus over the link with the getVirtualViewAt method (more info)

Implementing ExploreByTouchHelper to detect touches over links and display TalkBack rectangle around them.

  1. ReactAccessibilityDelegate inherits from ExploreByTouchHelper
  2. Implements the methods getVirtualViewAt and onPopulateBoundsForVirtualView. The two methods implements the following functionalities (more info):
    • detecting the TalkBack onPress/focus on nested Text with accessibilityRole="link"
    • displaying TalkBack rectangle around nested Text with accessibilityRole="link"

Changelog

[Android] [Added] - Make links independently focusable by Talkback

Test Plan

1. User Interacts with links through TalkBack default accessibility menu (link) 2. The nested link becomes the next focusable element after the parent element that contains it. (link) 3. Testing accessibility examples in pr branch (link) 4. Testing accessibility android examples in pr branch (link)

Test on main branch 5. Testing accessibility examples in main branch (link) 6. Testing accessibility android examples in main branch (link)

fabOnReact commented 2 years ago

TalkBack focus moves through links IN THE CORRECT ORDER from top to bottom (PR Branch with link.id)

Testing with the link.id in AccessibilityLink (discussion)

// ID is the reverse of what is expected, since the ClickableSpans are returned in reverse
// order due to being added in reverse order. If we don't do this, focus will move to the
// last link first and move backwards.
//
// If this approach becomes unreliable, we should instead look at their start position and
// order them manually.
link.id = spans.length - 1 - i;
links.add(link);

Expected Result: Swiping move TalkBack focus in this order:

  1. Parent Text This is a test of inline links in React Native. Here's another link. Here is a link that spans multiple lines because the text is so long. This sentence has no links in it. links available
  2. Nested Texts in order from top to bottom (test, inline links, another link, link that spans multiple lines...)

Links are displayed in the above order in the TalkBack menu

Actual Results:

RESULT 1 (SUCCESS) - Swiping moves TalkBack focus in the correct order

1. Parent Text `This is a test of inline links in React Native. Here's another link. Here is a link that spans multiple lines because the text is so long. This sentence has no links in it. links available` 2. Nested Texts in order from top to bottom

RESULT 2 (FAIL) - Links are NOT displayed in the correct order in the TalkBack menu

fabOnReact commented 2 years ago

TalkBack focus does NOT move through links in the correct order from top to bottom (PR Branch without link.id)

Testing without the link.id in AccessibilityLink (discussion)

// ID is the reverse of what is expected, since the ClickableSpans are returned in reverse
// order due to being added in reverse order. If we don't do this, focus will move to the
// last link first and move backwards.
//
// If this approach becomes unreliable, we should instead look at their start position and
// order them manually.
// link.id = spans.length - 1 - i;
links.add(link);

Expected Result: Swiping move TalkBack focus in this order:

  1. Parent Text This is a test of inline links in React Native. Here's another link. Here is a link that spans multiple lines because the text is so long. This sentence has no links in it. links available
  2. Nested Texts in order from top to bottom (test, inline links, another link, link that spans multiple lines...)

Links are displayed in the above order in the TalkBack menu

Actual Results:

RESULT 1 (FAIL) - Swiping moves TalkBack focus in this wrong order

1. Parent Text `This is a test of inline links in React Native. Here's another link. Here is a link that spans multiple lines because the text is so long. This sentence has no links in it. links available` 2. Swipe right or down moves the focus to the last link `link that spans multiple lines because the text is too long`.

RESULT 2 (FAIL) - Links are NOT displayed in the correct order in the TalkBack menu