mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
Add accessibilityValueDescription support. (#26169)
Summary: React Native components need a mechanism to specify their value to assistive technologies. This PR adds the notion of accessibilityValueDescription-- a property which either contains a textual description of a component's value, or for range-based components, such as sliders and progress bars, it contains range information (minimum, current, and maximum). On iOS, the range-based info if present is converted into a percentage and added to the accessibilityValue property of the UIView. If text is present as part of the accessibilityValueDescription, it is used instead of the range-based information. On Android, any range-based information in accessibilityValueDescription is exposed in the AccessibilityNodeInfo's RangeInfo. Text which is part of accessibilityValueDescription is appended to the content description. ## Changelog [GENERAL] [Change] - add accessibilityValuedescription property. Pull Request resolved: https://github.com/facebook/react-native/pull/26169 Test Plan: Added two new accessibility examples to RNTester, one which uses text and another which uses range-based info in accessibilityValueDescription. Verified that they both behave correctly on both Android and iOS. Differential Revision: D17444730 Pulled By: cpojer fbshipit-source-id: 1fb3252a90f88f7cafe1cbf7db08c03f14cc2321
This commit is contained in:
1 parent
7c8e266e39
commit
7df3eea1a7
14 files changed
+268
-3
No files matched your search
@@ -20,6 +20,7 @@ const UIView = {
|
||||
accessibilityLiveRegion: true,
|
||||
accessibilityRole: true,
|
||||
accessibilityState: true,
|
||||
accessibilityValue: true,
|
||||
accessibilityHint: true,
|
||||
importantForAccessibility: true,
|
||||
nativeID: true,
|
||||
|
||||
@@ -123,6 +123,7 @@ const ReactNativeViewConfig = {
|
||||
accessibilityRole: true,
|
||||
accessibilityStates: true, // TODO: Can be removed after next release
|
||||
accessibilityState: true,
|
||||
accessibilityValue: true,
|
||||
accessibilityViewIsModal: true,
|
||||
accessible: true,
|
||||
alignContent: true,
|
||||
|
||||
@@ -62,3 +62,25 @@ export type AccessibilityState = {
|
||||
busy?: boolean,
|
||||
expanded?: boolean,
|
||||
};
|
||||
|
||||
export type AccessibilityValue = $ReadOnly<{|
|
||||
/**
|
||||
* The minimum value of this component's range. (should be an integer)
|
||||
*/
|
||||
min?: number,
|
||||
|
||||
/**
|
||||
* The maximum value of this component's range. (should be an integer)
|
||||
*/
|
||||
max?: number,
|
||||
|
||||
/**
|
||||
* The current value of this component's range. (should be an integer)
|
||||
*/
|
||||
now?: number,
|
||||
|
||||
/**
|
||||
* A textual description of this component's value. (will override minimum, current, and maximum if set)
|
||||
*/
|
||||
text?: string,
|
||||
|}>;
|
||||
@@ -18,6 +18,7 @@ import type {TVViewProps} from '../AppleTV/TVViewPropTypes';
|
||||
import type {
|
||||
AccessibilityRole,
|
||||
AccessibilityState,
|
||||
AccessibilityValue,
|
||||
AccessibilityActionEvent,
|
||||
AccessibilityActionInfo,
|
||||
} from './ViewAccessibility';
|
||||
@@ -413,6 +414,7 @@ export type ViewProps = $ReadOnly<{|
|
||||
* Indicates to accessibility services that UI Component is in a specific State.
|
||||
*/
|
||||
accessibilityState?: ?AccessibilityState,
|
||||
accessibilityValue?: ?AccessibilityValue,
|
||||
|
||||
/**
|
||||
* Provides an array of custom actions available for accessibility.
|
||||
|
||||
@@ -102,6 +102,7 @@ module.exports = {
|
||||
>),
|
||||
|
||||
accessibilityState: PropTypes.object,
|
||||
accessibilityValue: PropTypes.object,
|
||||
/**
|
||||
* Indicates to accessibility services whether the user should be notified
|
||||
* when this view changes. Works for Android API >= 19 only.
|
||||
|
||||
@@ -512,6 +512,89 @@ class AccessibilityActionsExample extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSliderExample extends React.Component {
|
||||
state = {
|
||||
current: 50,
|
||||
textualValue: 'center',
|
||||
};
|
||||
|
||||
increment = () => {
|
||||
let newValue = this.state.current + 2;
|
||||
if (newValue > 100) {
|
||||
newValue = 100;
|
||||
}
|
||||
this.setState({
|
||||
current: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
decrement = () => {
|
||||
let newValue = this.state.current - 2;
|
||||
if (newValue < 0) {
|
||||
newValue = 0;
|
||||
}
|
||||
this.setState({
|
||||
current: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
accessible={true}
|
||||
accessibilityLabel="Fake Slider"
|
||||
accessibilityRole="adjustable"
|
||||
accessibilityActions={[{name: 'increment'}, {name: 'decrement'}]}
|
||||
onAccessibilityAction={event => {
|
||||
switch (event.nativeEvent.actionName) {
|
||||
case 'increment':
|
||||
this.increment();
|
||||
break;
|
||||
case 'decrement':
|
||||
this.decrement();
|
||||
break;
|
||||
}
|
||||
}}
|
||||
accessibilityValue={{
|
||||
min: 0,
|
||||
now: this.state.current,
|
||||
max: 100,
|
||||
}}>
|
||||
<Text>Fake Slider</Text>
|
||||
</View>
|
||||
<View
|
||||
accessible={true}
|
||||
accessibilityLabel="Equalizer"
|
||||
accessibilityRole="adjustable"
|
||||
accessibilityActions={[{name: 'increment'}, {name: 'decrement'}]}
|
||||
onAccessibilityAction={event => {
|
||||
switch (event.nativeEvent.actionName) {
|
||||
case 'increment':
|
||||
if (this.state.textualValue === 'center') {
|
||||
this.setState({textualValue: 'right'});
|
||||
} else if (this.state.textualValue === 'left') {
|
||||
this.setState({textualValue: 'center'});
|
||||
}
|
||||
break;
|
||||
case 'decrement':
|
||||
if (this.state.textualValue === 'center') {
|
||||
this.setState({textualValue: 'left'});
|
||||
} else if (this.state.textualValue === 'right') {
|
||||
this.setState({textualValue: 'center'});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}}
|
||||
accessibilityValue={{text: this.state.textualValue}}>
|
||||
<Text>Equalizer</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenReaderStatusExample extends React.Component<{}> {
|
||||
state = {
|
||||
screenReaderEnabled: false,
|
||||
@@ -591,6 +674,12 @@ exports.examples = [
|
||||
return <AccessibilityActionsExample />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Fake Slider Example',
|
||||
render(): React.Element<typeof FakeSliderExample> {
|
||||
return <FakeSliderExample />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Check if the screen reader is enabled',
|
||||
render(): React.Element<typeof ScreenReaderStatusExample> {
|
||||
|
||||
@@ -271,6 +271,26 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused)
|
||||
[valueComponents addObject:stateDescriptions[@"busy"]];
|
||||
}
|
||||
}
|
||||
|
||||
// handle accessibilityValue
|
||||
|
||||
if (self.accessibilityValueInternal) {
|
||||
id min = self.accessibilityValueInternal[@"min"];
|
||||
id now = self.accessibilityValueInternal[@"now"];
|
||||
id max = self.accessibilityValueInternal[@"max"];
|
||||
id text = self.accessibilityValueInternal[@"text"];
|
||||
if (text && [text isKindOfClass:[NSString class]]) {
|
||||
[valueComponents addObject:text];
|
||||
} else if ([min isKindOfClass:[NSNumber class]] &&
|
||||
[now isKindOfClass:[NSNumber class]] &&
|
||||
[max isKindOfClass:[NSNumber class]] &&
|
||||
([min intValue] < [max intValue]) &&
|
||||
([min intValue] <= [now intValue] && [now intValue] <= [max intValue])) {
|
||||
int val = ([now intValue]*100)/([max intValue]-[min intValue]);
|
||||
[valueComponents addObject:[NSString stringWithFormat:@"%d percent", val]];
|
||||
}
|
||||
}
|
||||
|
||||
if (valueComponents.count > 0) {
|
||||
return [valueComponents componentsJoinedByString:@", "];
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ RCT_REMAP_VIEW_PROPERTY(accessible, reactAccessibilityElement.isAccessibilityEle
|
||||
RCT_REMAP_VIEW_PROPERTY(accessibilityActions, reactAccessibilityElement.accessibilityActions, NSDictionaryArray)
|
||||
RCT_REMAP_VIEW_PROPERTY(accessibilityLabel, reactAccessibilityElement.accessibilityLabel, NSString)
|
||||
RCT_REMAP_VIEW_PROPERTY(accessibilityHint, reactAccessibilityElement.accessibilityHint, NSString)
|
||||
RCT_REMAP_VIEW_PROPERTY(accessibilityValue, reactAccessibilityElement.accessibilityValueInternal, NSDictionary)
|
||||
RCT_REMAP_VIEW_PROPERTY(accessibilityViewIsModal, reactAccessibilityElement.accessibilityViewIsModal, BOOL)
|
||||
RCT_REMAP_VIEW_PROPERTY(accessibilityElementsHidden, reactAccessibilityElement.accessibilityElementsHidden, BOOL)
|
||||
RCT_REMAP_VIEW_PROPERTY(accessibilityIgnoresInvertColors, reactAccessibilityElement.shouldAccessibilityIgnoresInvertColors, BOOL)
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
@property (nonatomic, copy) NSString *accessibilityRole;
|
||||
@property (nonatomic, copy) NSDictionary<NSString *, id> *accessibilityState;
|
||||
@property (nonatomic, copy) NSArray <NSDictionary *> *accessibilityActions;
|
||||
@property (nonatomic, copy) NSDictionary *accessibilityValueInternal;
|
||||
|
||||
/**
|
||||
* Used in debugging to get a description of the view hierarchy rooted at
|
||||
|
||||
@@ -327,8 +327,16 @@
|
||||
objc_setAssociatedObject(self, @selector(accessibilityState), accessibilityState, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
|
||||
#pragma mark - Debug
|
||||
- (NSDictionary<NSString *, id> *)accessibilityValueInternal
|
||||
{
|
||||
return objc_getAssociatedObject(self, _cmd);
|
||||
}
|
||||
- (void)setAccessibilityValueInternal:(NSDictionary<NSString *, id> *)accessibilityValue
|
||||
{
|
||||
objc_setAssociatedObject(self, @selector(accessibilityValueInternal), accessibilityValue, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
|
||||
#pragma mark - Debug
|
||||
- (void)react_addRecursiveDescriptionToString:(NSMutableString *)string atLevel:(NSUInteger)level
|
||||
{
|
||||
for (NSUInteger i = 0; i < level; i++) {
|
||||
|
||||
@@ -179,6 +179,7 @@ public abstract class BaseViewManager<T extends View, C extends LayoutShadowNode
|
||||
final ReadableMap accessibilityState = (ReadableMap) view.getTag(R.id.accessibility_state);
|
||||
final String accessibilityHint = (String) view.getTag(R.id.accessibility_hint);
|
||||
final List<String> contentDescription = new ArrayList<>();
|
||||
final ReadableMap accessibilityValue = (ReadableMap) view.getTag(R.id.accessibility_value);
|
||||
if (accessibilityLabel != null) {
|
||||
contentDescription.add(accessibilityLabel);
|
||||
}
|
||||
@@ -205,6 +206,12 @@ public abstract class BaseViewManager<T extends View, C extends LayoutShadowNode
|
||||
}
|
||||
}
|
||||
}
|
||||
if (accessibilityValue != null && accessibilityValue.hasKey("text")) {
|
||||
final Dynamic text = accessibilityValue.getDynamic("text");
|
||||
if (text != null && text.getType() == ReadableType.String) {
|
||||
contentDescription.add(text.asString());
|
||||
}
|
||||
}
|
||||
if (accessibilityHint != null) {
|
||||
contentDescription.add(accessibilityHint);
|
||||
}
|
||||
@@ -223,6 +230,18 @@ public abstract class BaseViewManager<T extends View, C extends LayoutShadowNode
|
||||
view.setTag(R.id.accessibility_actions, accessibilityActions);
|
||||
}
|
||||
|
||||
@ReactProp(name = ViewProps.ACCESSIBILITY_VALUE)
|
||||
public void setAccessibilityValue(T view, ReadableMap accessibilityValue) {
|
||||
if (accessibilityValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.setTag(R.id.accessibility_value, accessibilityValue);
|
||||
if (accessibilityValue.hasKey("text")) {
|
||||
updateViewContentDescription(view);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@ReactProp(name = ViewProps.IMPORTANT_FOR_ACCESSIBILITY)
|
||||
public void setImportantForAccessibility(
|
||||
|
||||
+98
-2
@@ -7,15 +7,18 @@ package com.facebook.react.uimanager;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Message;
|
||||
import android.text.SpannableString;
|
||||
import android.text.style.URLSpan;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.view.AccessibilityDelegateCompat;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
|
||||
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
|
||||
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat;
|
||||
import com.facebook.react.R;
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.Dynamic;
|
||||
@@ -36,6 +39,8 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat {
|
||||
|
||||
private static final String TAG = "ReactAccessibilityDelegate";
|
||||
private static int sCounter = 0x3f000000;
|
||||
private static final int TIMEOUT_SEND_ACCESSIBILITY_EVENT = 200;
|
||||
private static final int SEND_EVENT = 1;
|
||||
|
||||
public static final HashMap<String, Integer> sActionIdMap = new HashMap<>();
|
||||
|
||||
@@ -46,6 +51,21 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat {
|
||||
sActionIdMap.put("decrement", AccessibilityActionCompat.ACTION_SCROLL_BACKWARD.getId());
|
||||
}
|
||||
|
||||
private Handler mHandler;
|
||||
|
||||
/**
|
||||
* Schedule a command for sending an accessibility event. </br> Note: A command is used to ensure
|
||||
* that accessibility events are sent at most one in a given time frame to save system resources
|
||||
* while the progress changes quickly.
|
||||
*/
|
||||
private void scheduleAccessibilityEventSender(View host) {
|
||||
if (mHandler.hasMessages(SEND_EVENT, host)) {
|
||||
mHandler.removeMessages(SEND_EVENT, host);
|
||||
}
|
||||
Message msg = mHandler.obtainMessage(SEND_EVENT, host);
|
||||
mHandler.sendMessageDelayed(msg, TIMEOUT_SEND_ACCESSIBILITY_EVENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* These roles are defined by Google's TalkBack screen reader, and this list should be kept up to
|
||||
* date with their implementation. Details can be seen in their source code here:
|
||||
@@ -148,6 +168,14 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat {
|
||||
public ReactAccessibilityDelegate() {
|
||||
super();
|
||||
mAccessibilityActionsMap = new HashMap<Integer, String>();
|
||||
mHandler =
|
||||
new Handler() {
|
||||
@Override
|
||||
public void handleMessage(Message msg) {
|
||||
View host = (View) msg.obj;
|
||||
host.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -185,6 +213,61 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat {
|
||||
info.addAction(accessibilityAction);
|
||||
}
|
||||
}
|
||||
|
||||
// Process accessibilityValue
|
||||
|
||||
final ReadableMap accessibilityValue = (ReadableMap) host.getTag(R.id.accessibility_value);
|
||||
if (accessibilityValue != null
|
||||
&& accessibilityValue.hasKey("min")
|
||||
&& accessibilityValue.hasKey("now")
|
||||
&& accessibilityValue.hasKey("max")) {
|
||||
final Dynamic minDynamic = accessibilityValue.getDynamic("min");
|
||||
final Dynamic nowDynamic = accessibilityValue.getDynamic("now");
|
||||
final Dynamic maxDynamic = accessibilityValue.getDynamic("max");
|
||||
if (minDynamic != null
|
||||
&& minDynamic.getType() == ReadableType.Number
|
||||
&& nowDynamic != null
|
||||
&& nowDynamic.getType() == ReadableType.Number
|
||||
&& maxDynamic != null
|
||||
&& maxDynamic.getType() == ReadableType.Number) {
|
||||
final int min = minDynamic.asInt();
|
||||
final int now = nowDynamic.asInt();
|
||||
final int max = maxDynamic.asInt();
|
||||
if (max > min && now >= min && max >= now) {
|
||||
info.setRangeInfo(RangeInfoCompat.obtain(RangeInfoCompat.RANGE_TYPE_INT, min, max, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
|
||||
super.onInitializeAccessibilityEvent(host, event);
|
||||
// Set item count and current item index on accessibility events for adjustable
|
||||
// in order to make Talkback announce the value of the adjustable
|
||||
final ReadableMap accessibilityValue = (ReadableMap) host.getTag(R.id.accessibility_value);
|
||||
if (accessibilityValue != null
|
||||
&& accessibilityValue.hasKey("min")
|
||||
&& accessibilityValue.hasKey("now")
|
||||
&& accessibilityValue.hasKey("max")) {
|
||||
final Dynamic minDynamic = accessibilityValue.getDynamic("min");
|
||||
final Dynamic nowDynamic = accessibilityValue.getDynamic("now");
|
||||
final Dynamic maxDynamic = accessibilityValue.getDynamic("max");
|
||||
if (minDynamic != null
|
||||
&& minDynamic.getType() == ReadableType.Number
|
||||
&& nowDynamic != null
|
||||
&& nowDynamic.getType() == ReadableType.Number
|
||||
&& maxDynamic != null
|
||||
&& maxDynamic.getType() == ReadableType.Number) {
|
||||
final int min = minDynamic.asInt();
|
||||
final int now = nowDynamic.asInt();
|
||||
final int max = maxDynamic.asInt();
|
||||
if (max > min && now >= min && max >= now) {
|
||||
event.setItemCount(max - min);
|
||||
event.setCurrentItemIndex(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -196,6 +279,20 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat {
|
||||
reactContext
|
||||
.getJSModule(RCTEventEmitter.class)
|
||||
.receiveEvent(host.getId(), "topAccessibilityAction", event);
|
||||
|
||||
// In order to make Talkback announce the change of the adjustable's value,
|
||||
// schedule to send a TYPE_VIEW_SELECTED event after performing the scroll actions.
|
||||
final AccessibilityRole accessibilityRole =
|
||||
(AccessibilityRole) host.getTag(R.id.accessibility_role);
|
||||
final ReadableMap accessibilityValue = (ReadableMap) host.getTag(R.id.accessibility_value);
|
||||
if (accessibilityRole == AccessibilityRole.ADJUSTABLE
|
||||
&& (action == AccessibilityActionCompat.ACTION_SCROLL_FORWARD.getId()
|
||||
|| action == AccessibilityActionCompat.ACTION_SCROLL_BACKWARD.getId())) {
|
||||
if (accessibilityValue != null && !accessibilityValue.hasKey("text")) {
|
||||
scheduleAccessibilityEventSender(host);
|
||||
}
|
||||
return super.performAccessibilityAction(host, action, args);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return super.performAccessibilityAction(host, action, args);
|
||||
@@ -203,7 +300,6 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat {
|
||||
|
||||
private static void setState(
|
||||
AccessibilityNodeInfoCompat info, ReadableMap accessibilityState, Context context) {
|
||||
Log.d(TAG, "setState " + accessibilityState);
|
||||
final ReadableMapKeySetIterator i = accessibilityState.keySetIterator();
|
||||
while (i.hasNextKey()) {
|
||||
final String state = i.nextKey();
|
||||
|
||||
@@ -141,6 +141,7 @@ public class ViewProps {
|
||||
public static final String ACCESSIBILITY_ROLE = "accessibilityRole";
|
||||
public static final String ACCESSIBILITY_STATE = "accessibilityState";
|
||||
public static final String ACCESSIBILITY_ACTIONS = "accessibilityActions";
|
||||
public static final String ACCESSIBILITY_VALUE = "accessibilityValue";
|
||||
public static final String IMPORTANT_FOR_ACCESSIBILITY = "importantForAccessibility";
|
||||
|
||||
// DEPRECATED
|
||||
|
||||
@@ -24,4 +24,7 @@
|
||||
<!--tag is used to store accessibilityActions tag-->
|
||||
<item type="id" name="accessibility_actions"/>
|
||||
|
||||
<!--tag is used to store accessibilityValue tag -->
|
||||
<item type="id" name="accessibility_value"/>
|
||||
|
||||
</resources>
|
||||
Reference in new issue
Block a user