From 7df3eea1a79f12c2dfff1976d0cef605a83232ec Mon Sep 17 00:00:00 2001 From: Marc Mulcahy Date: Wed, 18 Sep 2019 03:14:39 -0700 Subject: [PATCH] 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 --- .../View/ReactNativeViewAttributes.js | 1 + .../View/ReactNativeViewViewConfig.js | 1 + .../Components/View/ViewAccessibility.js | 22 ++++ Libraries/Components/View/ViewPropTypes.js | 2 + .../DeprecatedViewPropTypes.js | 1 + .../Accessibility/AccessibilityExample.js | 89 ++++++++++++++++ React/Views/RCTView.m | 20 ++++ React/Views/RCTViewManager.m | 1 + React/Views/UIView+React.h | 1 + React/Views/UIView+React.m | 10 +- .../react/uimanager/BaseViewManager.java | 19 ++++ .../uimanager/ReactAccessibilityDelegate.java | 100 +++++++++++++++++- .../facebook/react/uimanager/ViewProps.java | 1 + .../main/res/views/uimanager/values/ids.xml | 3 + 14 files changed, 268 insertions(+), 3 deletions(-) diff --git a/Libraries/Components/View/ReactNativeViewAttributes.js b/Libraries/Components/View/ReactNativeViewAttributes.js index 5d0ae6d3251..50438910f24 100644 --- a/Libraries/Components/View/ReactNativeViewAttributes.js +++ b/Libraries/Components/View/ReactNativeViewAttributes.js @@ -20,6 +20,7 @@ const UIView = { accessibilityLiveRegion: true, accessibilityRole: true, accessibilityState: true, + accessibilityValue: true, accessibilityHint: true, importantForAccessibility: true, nativeID: true, diff --git a/Libraries/Components/View/ReactNativeViewViewConfig.js b/Libraries/Components/View/ReactNativeViewViewConfig.js index 32d40459537..e6fdf8e58d9 100644 --- a/Libraries/Components/View/ReactNativeViewViewConfig.js +++ b/Libraries/Components/View/ReactNativeViewViewConfig.js @@ -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, diff --git a/Libraries/Components/View/ViewAccessibility.js b/Libraries/Components/View/ViewAccessibility.js index ef7b2996ffb..ef003c40704 100644 --- a/Libraries/Components/View/ViewAccessibility.js +++ b/Libraries/Components/View/ViewAccessibility.js @@ -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, +|}>; diff --git a/Libraries/Components/View/ViewPropTypes.js b/Libraries/Components/View/ViewPropTypes.js index 91b842f1b2e..2080979a2c5 100644 --- a/Libraries/Components/View/ViewPropTypes.js +++ b/Libraries/Components/View/ViewPropTypes.js @@ -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. diff --git a/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js b/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js index 4ad60c5389a..8cb8ceddfb2 100644 --- a/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js +++ b/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js @@ -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. diff --git a/RNTester/js/examples/Accessibility/AccessibilityExample.js b/RNTester/js/examples/Accessibility/AccessibilityExample.js index 39ba0ab1bc3..e910fcf02c5 100644 --- a/RNTester/js/examples/Accessibility/AccessibilityExample.js +++ b/RNTester/js/examples/Accessibility/AccessibilityExample.js @@ -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 ( + + { + switch (event.nativeEvent.actionName) { + case 'increment': + this.increment(); + break; + case 'decrement': + this.decrement(); + break; + } + }} + accessibilityValue={{ + min: 0, + now: this.state.current, + max: 100, + }}> + Fake Slider + + { + 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}}> + Equalizer + + + ); + } +} + class ScreenReaderStatusExample extends React.Component<{}> { state = { screenReaderEnabled: false, @@ -591,6 +674,12 @@ exports.examples = [ return ; }, }, + { + title: 'Fake Slider Example', + render(): React.Element { + return ; + }, + }, { title: 'Check if the screen reader is enabled', render(): React.Element { diff --git a/React/Views/RCTView.m b/React/Views/RCTView.m index e7865f4ea8e..579c2d17a0b 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -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:@", "]; } diff --git a/React/Views/RCTViewManager.m b/React/Views/RCTViewManager.m index 53dd7b77ab4..771caf6084c 100644 --- a/React/Views/RCTViewManager.m +++ b/React/Views/RCTViewManager.m @@ -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) diff --git a/React/Views/UIView+React.h b/React/Views/UIView+React.h index 5c855acb0a6..adcaecd9b69 100644 --- a/React/Views/UIView+React.h +++ b/React/Views/UIView+React.h @@ -119,6 +119,7 @@ @property (nonatomic, copy) NSString *accessibilityRole; @property (nonatomic, copy) NSDictionary *accessibilityState; @property (nonatomic, copy) NSArray *accessibilityActions; +@property (nonatomic, copy) NSDictionary *accessibilityValueInternal; /** * Used in debugging to get a description of the view hierarchy rooted at diff --git a/React/Views/UIView+React.m b/React/Views/UIView+React.m index 6309bbe3b55..cb839fdd4b1 100644 --- a/React/Views/UIView+React.m +++ b/React/Views/UIView+React.m @@ -327,8 +327,16 @@ objc_setAssociatedObject(self, @selector(accessibilityState), accessibilityState, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } -#pragma mark - Debug +- (NSDictionary *)accessibilityValueInternal +{ + return objc_getAssociatedObject(self, _cmd); +} +- (void)setAccessibilityValueInternal:(NSDictionary *)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++) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 4198a41da70..9c8b35b8ea5 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -179,6 +179,7 @@ public abstract class BaseViewManager 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 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.
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(); + 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(); diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java index 72ebb658546..41d9ed50a19 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java @@ -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 diff --git a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index ff82dd20c47..6886defd469 100644 --- a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -24,4 +24,7 @@ + + +