From 099be9b35634851b178e990c47358c2129c0dd7d Mon Sep 17 00:00:00 2001 From: Marc Mulcahy Date: Thu, 23 May 2019 05:27:39 -0700 Subject: [PATCH] New Accessibility states API. (#24608) Summary: As currently defined, accessibilityStates is an array of strings, which represents the state of an object. The array of strings notion doesn't well encapsulate how various states are related, nor enforce any level of correctness. This PR converts accessibilityStates to an object with a specific definition. So, rather than: We have: And specifically define the checked state to either take a boolean or the "mixed" string (to represent mixed checkboxes). We feel this API is easier to understand an implement, and provides better semantic definition of the states themselves, and how states are related to one another. ## Changelog [general] [change] - Convert accessibilityStates to an object instead of an array of strings. Pull Request resolved: https://github.com/facebook/react-native/pull/24608 Differential Revision: D15467980 Pulled By: cpojer fbshipit-source-id: f0414c0ef6add3f10f7f551d323d82d978754278 --- Libraries/Components/TextInput/TextInput.js | 3 + .../Components/Touchable/TouchableBounce.js | 1 + .../Touchable/TouchableHighlight.js | 1 + .../TouchableNativeFeedback.android.js | 1 + .../Components/Touchable/TouchableOpacity.js | 1 + .../Touchable/TouchableWithoutFeedback.js | 9 ++- .../View/ReactNativeViewAttributes.js | 1 + .../View/ReactNativeViewViewConfig.js | 1 + .../Components/View/ViewAccessibility.js | 8 ++ Libraries/Components/View/ViewPropTypes.js | 2 + .../DeprecatedViewPropTypes.js | 1 + Libraries/Text/TextProps.js | 2 + RNTester/js/AccessibilityExample.js | 52 +++++++------ React/Views/RCTView.m | 31 +++++++- React/Views/RCTViewManager.m | 32 ++++++++ React/Views/UIView+React.h | 1 + React/Views/UIView+React.m | 10 +++ .../react/uimanager/BaseViewManager.java | 73 +++++++++++++++++-- .../uimanager/ReactAccessibilityDelegate.java | 48 ++++++++++-- .../main/res/views/uimanager/values/ids.xml | 3 + .../uimanager/values/strings_unlocalized.xml | 4 + 21 files changed, 242 insertions(+), 43 deletions(-) diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index 61441eb5af2dee..3caf05f6bacb88 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -1104,6 +1104,7 @@ const TextInput = createReactClass({ accessibilityLabel={props.accessibilityLabel} accessibilityRole={props.accessibilityRole} accessibilityStates={props.accessibilityStates} + accessibilityState={props.accessibilityState} nativeID={this.props.nativeID} testID={props.testID}> {textContainer} @@ -1156,6 +1157,7 @@ const TextInput = createReactClass({ accessibilityLabel={props.accessibilityLabel} accessibilityRole={props.accessibilityRole} accessibilityStates={props.accessibilityStates} + accessibilityState={props.accessibilityState} nativeID={this.props.nativeID} testID={props.testID}> {textContainer} @@ -1213,6 +1215,7 @@ const TextInput = createReactClass({ accessibilityLabel={this.props.accessibilityLabel} accessibilityRole={this.props.accessibilityRole} accessibilityStates={this.props.accessibilityStates} + accessibilityState={this.props.accessibilityState} nativeID={this.props.nativeID} testID={this.props.testID}> {textContainer} diff --git a/Libraries/Components/Touchable/TouchableBounce.js b/Libraries/Components/Touchable/TouchableBounce.js index 1010be60870a24..e9937dfd70a844 100644 --- a/Libraries/Components/Touchable/TouchableBounce.js +++ b/Libraries/Components/Touchable/TouchableBounce.js @@ -181,6 +181,7 @@ const TouchableBounce = ((createReactClass({ accessibilityHint={this.props.accessibilityHint} accessibilityRole={this.props.accessibilityRole} accessibilityStates={this.props.accessibilityStates} + accessibilityState={this.props.accessibilityState} nativeID={this.props.nativeID} testID={this.props.testID} hitSlop={this.props.hitSlop} diff --git a/Libraries/Components/Touchable/TouchableHighlight.js b/Libraries/Components/Touchable/TouchableHighlight.js index b9e552937b4949..5d733edb0a2c72 100644 --- a/Libraries/Components/Touchable/TouchableHighlight.js +++ b/Libraries/Components/Touchable/TouchableHighlight.js @@ -408,6 +408,7 @@ const TouchableHighlight = ((createReactClass({ accessibilityHint={this.props.accessibilityHint} accessibilityRole={this.props.accessibilityRole} accessibilityStates={this.props.accessibilityStates} + accessibilityState={this.props.accessibilityState} style={StyleSheet.compose( this.props.style, this.state.extraUnderlayStyle, diff --git a/Libraries/Components/Touchable/TouchableNativeFeedback.android.js b/Libraries/Components/Touchable/TouchableNativeFeedback.android.js index 1bd0d27cab21da..1f10ff412bd6e7 100644 --- a/Libraries/Components/Touchable/TouchableNativeFeedback.android.js +++ b/Libraries/Components/Touchable/TouchableNativeFeedback.android.js @@ -314,6 +314,7 @@ const TouchableNativeFeedback = createReactClass({ accessibilityLabel: this.props.accessibilityLabel, accessibilityRole: this.props.accessibilityRole, accessibilityStates: this.props.accessibilityStates, + accessibilityState: this.props.accessibilityState, children, testID: this.props.testID, onLayout: this.props.onLayout, diff --git a/Libraries/Components/Touchable/TouchableOpacity.js b/Libraries/Components/Touchable/TouchableOpacity.js index 235d47ff9af197..adc6439584aa33 100644 --- a/Libraries/Components/Touchable/TouchableOpacity.js +++ b/Libraries/Components/Touchable/TouchableOpacity.js @@ -311,6 +311,7 @@ const TouchableOpacity = ((createReactClass({ accessibilityHint={this.props.accessibilityHint} accessibilityRole={this.props.accessibilityRole} accessibilityStates={this.props.accessibilityStates} + accessibilityState={this.props.accessibilityState} style={[this.props.style, {opacity: this.state.anim}]} nativeID={this.props.nativeID} testID={this.props.testID} diff --git a/Libraries/Components/Touchable/TouchableWithoutFeedback.js b/Libraries/Components/Touchable/TouchableWithoutFeedback.js index 887a9805309f63..6323cee0ed977e 100755 --- a/Libraries/Components/Touchable/TouchableWithoutFeedback.js +++ b/Libraries/Components/Touchable/TouchableWithoutFeedback.js @@ -21,7 +21,6 @@ const ensurePositiveDelayProps = require('./ensurePositiveDelayProps'); const { DeprecatedAccessibilityRoles, - DeprecatedAccessibilityStates, } = require('../../DeprecatedPropTypes/DeprecatedViewAccessibility'); import type { @@ -33,6 +32,7 @@ import type {EdgeInsetsProp} from '../../StyleSheet/EdgeInsetsPropType'; import type { AccessibilityRole, AccessibilityStates, + AccessibilityState, } from '../View/ViewAccessibility'; type TargetEvent = SyntheticEvent< @@ -52,6 +52,7 @@ const OVERRIDE_PROPS = [ 'accessibilityIgnoresInvertColors', 'accessibilityRole', 'accessibilityStates', + 'accessibilityState', 'hitSlop', 'nativeID', 'onBlur', @@ -67,6 +68,7 @@ export type Props = $ReadOnly<{| accessibilityIgnoresInvertColors?: ?boolean, accessibilityRole?: ?AccessibilityRole, accessibilityStates?: ?AccessibilityStates, + accessibilityState?: ?AccessibilityState, children?: ?React.Node, delayLongPress?: ?number, delayPressIn?: ?number, @@ -104,9 +106,8 @@ const TouchableWithoutFeedback = ((createReactClass({ accessibilityHint: PropTypes.string, accessibilityIgnoresInvertColors: PropTypes.bool, accessibilityRole: PropTypes.oneOf(DeprecatedAccessibilityRoles), - accessibilityStates: PropTypes.arrayOf( - PropTypes.oneOf(DeprecatedAccessibilityStates), - ), + accessibilityStates: PropTypes.array, + accessibilityState: PropTypes.object, /** * When `accessible` is true (which is the default) this may be called when * the OS-specific concept of "focus" occurs. Some platforms may not have diff --git a/Libraries/Components/View/ReactNativeViewAttributes.js b/Libraries/Components/View/ReactNativeViewAttributes.js index b4a3b69040e311..92837b4bed1820 100644 --- a/Libraries/Components/View/ReactNativeViewAttributes.js +++ b/Libraries/Components/View/ReactNativeViewAttributes.js @@ -22,6 +22,7 @@ ReactNativeViewAttributes.UIView = { accessibilityLiveRegion: true, accessibilityRole: true, accessibilityStates: true, + accessibilityState: true, accessibilityHint: true, importantForAccessibility: true, nativeID: true, diff --git a/Libraries/Components/View/ReactNativeViewViewConfig.js b/Libraries/Components/View/ReactNativeViewViewConfig.js index 0a65d0188203a3..d723f2797e7de1 100644 --- a/Libraries/Components/View/ReactNativeViewViewConfig.js +++ b/Libraries/Components/View/ReactNativeViewViewConfig.js @@ -110,6 +110,7 @@ const ReactNativeViewConfig = { accessibilityLiveRegion: true, accessibilityRole: true, accessibilityStates: true, + accessibilityState: true, accessibilityViewIsModal: true, accessible: true, alignContent: true, diff --git a/Libraries/Components/View/ViewAccessibility.js b/Libraries/Components/View/ViewAccessibility.js index 6f40d3d3c78cf7..711b57a77d68a7 100644 --- a/Libraries/Components/View/ViewAccessibility.js +++ b/Libraries/Components/View/ViewAccessibility.js @@ -66,3 +66,11 @@ export type AccessibilityActionEvent = SyntheticEvent< actionName: string, }>, >; + +export type AccessibilityState = { + disabled?: boolean, + selected?: boolean, + checked?: ?boolean | 'mixed', + busy?: boolean, + expanded?: boolean, +}; diff --git a/Libraries/Components/View/ViewPropTypes.js b/Libraries/Components/View/ViewPropTypes.js index 861f105b455995..e3a53b8e84d79a 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, AccessibilityStates, + AccessibilityState, AccessibilityActionEvent, AccessibilityActionInfo, } from './ViewAccessibility'; @@ -413,6 +414,7 @@ export type ViewProps = $ReadOnly<{| * Indicates to accessibility services that UI Component is in a specific State. */ accessibilityStates?: ?AccessibilityStates, + accessibilityState?: ?AccessibilityState, /** * Provides an array of custom actions available for accessibility. diff --git a/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js b/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js index 01f035858cb607..343513b5cf3570 100644 --- a/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js +++ b/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js @@ -78,6 +78,7 @@ module.exports = { accessibilityStates: PropTypes.arrayOf( PropTypes.oneOf(DeprecatedAccessibilityStates), ), + accessibilityState: 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/Libraries/Text/TextProps.js b/Libraries/Text/TextProps.js index 2e55b7c4fa69d6..21951fe145c7f0 100644 --- a/Libraries/Text/TextProps.js +++ b/Libraries/Text/TextProps.js @@ -20,6 +20,7 @@ import type {TextStyleProp} from '../StyleSheet/StyleSheet'; import type { AccessibilityRole, AccessibilityStates, + AccessibilityState, } from '../Components/View/ViewAccessibility'; export type PressRetentionOffset = $ReadOnly<{| @@ -43,6 +44,7 @@ export type TextProps = $ReadOnly<{| accessibilityLabel?: ?Stringish, accessibilityRole?: ?AccessibilityRole, accessibilityStates?: ?AccessibilityStates, + accessibilityState?: ?AccessibilityState, /** * Whether font should be scaled down automatically. diff --git a/RNTester/js/AccessibilityExample.js b/RNTester/js/AccessibilityExample.js index c5ae027942e4d8..a12602c5291c59 100644 --- a/RNTester/js/AccessibilityExample.js +++ b/RNTester/js/AccessibilityExample.js @@ -109,7 +109,7 @@ class AccessibilityExample extends React.Component { Alert.alert('Button has been pressed!')} accessibilityRole="button" - accessibilityStates={['disabled']} + accessibilityState={{disabled: true}} disabled={true}> @@ -122,7 +122,7 @@ class AccessibilityExample extends React.Component { + accessibilityState={{selected: true, disabled: true}}> This view is selected and disabled. @@ -132,7 +132,7 @@ class AccessibilityExample extends React.Component { accessible={true} accessibilityLabel="Accessibility label." accessibilityRole="button" - accessibilityStates={['selected']} + accessibilityState={{selected: true}} accessibilityHint="Accessibility hint."> Accessible view with label, hint, role, and state @@ -144,12 +144,18 @@ class AccessibilityExample extends React.Component { class CheckboxExample extends React.Component { state = { - checkboxState: 'checked', + checkboxState: true, }; _onCheckboxPress = () => { - const checkboxState = - this.state.checkboxState === 'checked' ? 'unchecked' : 'checked'; + 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, @@ -169,7 +175,7 @@ class CheckboxExample extends React.Component { onPress={this._onCheckboxPress} accessibilityLabel="element 2" accessibilityRole="checkbox" - accessibilityStates={[this.state.checkboxState]} + accessibilityState={{checked: this.state.checkboxState}} accessibilityHint="click me to change state"> Checkbox example @@ -179,12 +185,11 @@ class CheckboxExample extends React.Component { class SwitchExample extends React.Component { state = { - switchState: 'checked', + switchState: true, }; _onSwitchToggle = () => { - const switchState = - this.state.switchState === 'checked' ? 'unchecked' : 'checked'; + const switchState = !this.state.switchState; this.setState({ switchState: switchState, @@ -204,7 +209,7 @@ class SwitchExample extends React.Component { onPress={this._onSwitchToggle} accessibilityLabel="element 12" accessibilityRole="switch" - accessibilityStates={[this.state.switchState]} + accessibilityState={{checked: this.state.switchState}} accessible={true}> Switch example @@ -224,14 +229,11 @@ class SelectionExample extends React.Component { }; render() { - let accessibilityStates = []; let accessibilityHint = 'click me to select'; if (this.state.isSelected) { - accessibilityStates.push('selected'); accessibilityHint = 'click me to unselect'; } if (!this.state.isEnabled) { - accessibilityStates.push('disabled'); accessibilityHint = 'use the button on the right to enable selection'; } let buttonTitle = this.state.isEnabled @@ -244,9 +246,11 @@ class SelectionExample extends React.Component { ref={this.selectableElement} accessible={true} onPress={() => { - this.setState({ - isSelected: !this.state.isSelected, - }); + if (this.state.isEnabled) { + this.setState({ + isSelected: !this.state.isSelected, + }); + } if (Platform.OS === 'android') { UIManager.sendAccessibilityEvent( @@ -256,7 +260,10 @@ class SelectionExample extends React.Component { } }} accessibilityLabel="element 19" - accessibilityStates={accessibilityStates} + accessibilityState={{ + selected: this.state.isSelected, + disabled: !this.state.isEnabled, + }} accessibilityHint={accessibilityHint}> Selectable element example @@ -275,12 +282,11 @@ class SelectionExample extends React.Component { class ExpandableElementExample extends React.Component { state = { - expandState: 'collapsed', + expandState: false, }; _onElementPress = () => { - const expandState = - this.state.expandState === 'collapsed' ? 'expanded' : 'collapsed'; + const expandState = !this.state.expandState; this.setState({ expandState: expandState, @@ -299,7 +305,7 @@ class ExpandableElementExample extends React.Component { Expandable element example @@ -399,7 +405,7 @@ class AccessibilityRoleAndStateExample extends React.Component<{}> { State busy example diff --git a/React/Views/RCTView.m b/React/Views/RCTView.m index 39cdcaa974ae94..8f550ab57cbdf3 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -222,6 +222,15 @@ - (NSString *)accessibilityValue return @"0"; } } + for (NSString *state in self.accessibilityState) { + id val = self.accessibilityState[state]; + if (!val) { + continue; + } + if ([state isEqualToString:@"checked"] && [val isKindOfClass:[NSNumber class]]) { + return [val boolValue] ? @"1" : @"0"; + } + } } NSMutableArray *valueComponents = [NSMutableArray new]; static NSDictionary *roleDescriptions = nil; @@ -255,6 +264,7 @@ - (NSString *)accessibilityValue @"busy" : @"busy", @"expanded" : @"expanded", @"collapsed" : @"collapsed", + @"mixed": @"mixed", }; }); NSString *roleDescription = self.accessibilityRole ? roleDescriptions[self.accessibilityRole]: nil; @@ -267,8 +277,27 @@ - (NSString *)accessibilityValue [valueComponents addObject:stateDescription]; } } + for (NSString *state in self.accessibilityState) { + id val = self.accessibilityState[state]; + if (!val) { + continue; + } + if ([state isEqualToString:@"checked"]) { + if ([val isKindOfClass:[NSNumber class]]) { + [valueComponents addObject:stateDescriptions[[val boolValue] ? @"checked" : @"unchecked"]]; + } else if ([val isKindOfClass:[NSString class]] && [val isEqualToString:@"mixed"]) { + [valueComponents addObject:stateDescriptions[@"mixed"]]; + } + } + if ([state isEqualToString:@"expanded"] && [val isKindOfClass:[NSNumber class]]) { + [valueComponents addObject:stateDescriptions[[val boolValue] ? @"expanded" : @"collapsed"]]; + } + if ([state isEqualToString:@"busy"] && [val isKindOfClass:[NSNumber class]] && [val boolValue]) { + [valueComponents addObject:stateDescriptions[@"busy"]]; + } + } if (valueComponents.count > 0) { - return [valueComponents componentsJoinedByString:@", "]; + return [valueComponents componentsJoinedByString:@", "]; } return nil; } diff --git a/React/Views/RCTViewManager.m b/React/Views/RCTViewManager.m index 7c538ed2e200bc..bdc9ecccd84b87 100644 --- a/React/Views/RCTViewManager.m +++ b/React/Views/RCTViewManager.m @@ -206,6 +206,38 @@ - (RCTShadowView *)shadowView } } +RCT_CUSTOM_VIEW_PROPERTY(accessibilityState, NSDictionary, RCTView) +{ + NSDictionary *state = json ? [RCTConvert NSDictionary:json] : nil; + NSMutableDictionary *newState = [[NSMutableDictionary alloc] init]; + + if (!state) { + return; + } + + const UIAccessibilityTraits AccessibilityStatesMask = UIAccessibilityTraitNotEnabled | UIAccessibilityTraitSelected; + view.reactAccessibilityElement.accessibilityTraits = view.reactAccessibilityElement.accessibilityTraits & ~AccessibilityStatesMask; + + for (NSString *s in state) { + id val = [state objectForKey:s]; + if (!val) { + continue; + } + if ([s isEqualToString:@"selected"] && [val isKindOfClass:[NSNumber class]] && [val boolValue]) { + view.reactAccessibilityElement.accessibilityTraits |= UIAccessibilityTraitSelected; + } else if ([s isEqualToString:@"disabled"] && [val isKindOfClass:[NSNumber class]] && [val boolValue]) { + view.reactAccessibilityElement.accessibilityTraits |= UIAccessibilityTraitNotEnabled; + } else { + newState[s] = val; + } + } + if (newState.count > 0) { + view.reactAccessibilityElement.accessibilityState = newState; + } else { + view.reactAccessibilityElement.accessibilityState = nil; + } +} + RCT_CUSTOM_VIEW_PROPERTY(nativeID, NSString *, RCTView) { view.nativeID = json ? [RCTConvert NSString:json] : defaultView.nativeID; diff --git a/React/Views/UIView+React.h b/React/Views/UIView+React.h index c4cab30b60a72f..6fbf47a0739299 100644 --- a/React/Views/UIView+React.h +++ b/React/Views/UIView+React.h @@ -118,6 +118,7 @@ */ @property (nonatomic, copy) NSString *accessibilityRole; @property (nonatomic, copy) NSArray *accessibilityStates; +@property (nonatomic, copy) NSDictionary *accessibilityState; /** * 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 019eebcc120f7f..d9c4d4190ab226 100644 --- a/React/Views/UIView+React.m +++ b/React/Views/UIView+React.m @@ -327,6 +327,16 @@ - (void)setAccessibilityStates:(NSArray *)accessibilityStates objc_setAssociatedObject(self, @selector(accessibilityStates), accessibilityStates, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } +- (NSDictionary *)accessibilityState +{ + return objc_getAssociatedObject(self, _cmd); +} + +- (void)setAccessibilityState:(NSDictionary *)accessibilityState +{ + objc_setAssociatedObject(self, @selector(accessibilityState), accessibilityState, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + #pragma mark - Debug - (void)react_addRecursiveDescriptionToString:(NSMutableString *)string atLevel:(NSUInteger)level 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 0d63d86cb1f15b..94bfdbf91fc47b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -6,14 +6,21 @@ package com.facebook.react.uimanager; import android.graphics.Color; +import android.text.TextUtils; import android.view.View; import android.view.ViewParent; + import androidx.core.view.ViewCompat; +import java.util.ArrayList; import java.util.HashMap; import com.facebook.react.R; +import com.facebook.react.bridge.Dynamic; import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.ReadableType; import com.facebook.react.common.MapBuilder; import com.facebook.react.uimanager.ReactAccessibilityDelegate; import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole; @@ -21,6 +28,7 @@ import com.facebook.react.uimanager.util.ReactFindViewUtil; import javax.annotation.Nonnull; +import java.util.ArrayList; import java.util.Map; import javax.annotation.Nullable; @@ -41,6 +49,7 @@ public abstract class BaseViewManager sStateDescription= new HashMap(); + public static final HashMap sStateDescription = new HashMap(); static { sStateDescription.put("busy", R.string.state_busy_description); sStateDescription.put("expanded", R.string.state_expanded_description); sStateDescription.put("collapsed", R.string.state_collapsed_description); } + // State definition constants -- must match the definition in + // ViewAccessibility.js. These only include states for which there + // is no native support in android. + + private static final String STATE_CHECKED = "checked"; // Special case for mixed state checkboxes + private static final String STATE_BUSY = "busy"; + private static final String STATE_EXPANDED = "expanded"; + private static final String STATE_MIXED = "mixed"; + @ReactProp(name = PROP_BACKGROUND_COLOR, defaultInt = Color.TRANSPARENT, customType = "Color") public void setBackgroundColor(@Nonnull T view, int backgroundColor) { view.setBackgroundColor(backgroundColor); @@ -169,27 +187,66 @@ public void setViewStates(@Nonnull T view, @Nullable ReadableArray accessibility } } + @ReactProp(name = PROP_ACCESSIBILITY_STATE) + public void setViewState(@Nonnull T view, @Nullable ReadableMap accessibilityState) { + if (accessibilityState == null) { + return; + } + view.setTag(R.id.accessibility_state, accessibilityState); + view.setSelected(false); + view.setEnabled(true); + + // For states which don't have corresponding methods in + // AccessibilityNodeInfo, update the view's content description + // here + + final ReadableMapKeySetIterator i = accessibilityState.keySetIterator(); + while (i.hasNextKey()) { + final String state = i.nextKey(); + if (state.equals(STATE_BUSY) || state.equals(STATE_EXPANDED) || + (state.equals(STATE_CHECKED) && accessibilityState.getType(STATE_CHECKED) == ReadableType.String)) { + updateViewContentDescription(view); + break; + } + } + } + private void updateViewContentDescription(@Nonnull T view) { final String accessibilityLabel = (String) view.getTag(R.id.accessibility_label); final ReadableArray accessibilityStates = (ReadableArray) view.getTag(R.id.accessibility_states); + final ReadableMap accessibilityState = (ReadableMap) view.getTag(R.id.accessibility_state); final String accessibilityHint = (String) view.getTag(R.id.accessibility_hint); - StringBuilder contentDescription = new StringBuilder(); + final ArrayList contentDescription = new ArrayList(); if (accessibilityLabel != null) { - contentDescription.append(accessibilityLabel + ", "); + contentDescription.add(accessibilityLabel); } if (accessibilityStates != null) { for (int i = 0; i < accessibilityStates.size(); i++) { - String state = accessibilityStates.getString(i); + final String state = accessibilityStates.getString(i); if (sStateDescription.containsKey(state)) { - contentDescription.append(view.getContext().getString(sStateDescription.get(state)) + ", "); + contentDescription.add(view.getContext().getString(sStateDescription.get(state))); + } + } + } + if (accessibilityState != null) { + final ReadableMapKeySetIterator i = accessibilityState.keySetIterator(); + while (i.hasNextKey()) { + final String state = i.nextKey(); + final Dynamic value = accessibilityState.getDynamic(state); + if (state.equals(STATE_CHECKED) && value.getType() == ReadableType.String && value.asString().equals(STATE_MIXED)) { + contentDescription.add(view.getContext().getString(R.string.state_mixed_description)); + } else if (state.equals(STATE_BUSY) && value.getType() == ReadableType.Boolean && value.asBoolean()) { + contentDescription.add(view.getContext().getString(R.string.state_busy_description)); + } else if (state.equals(STATE_EXPANDED) && value.getType() == ReadableType.Boolean) { + contentDescription.add(view.getContext().getString(value.asBoolean() ? R.string.state_expanded_description : R.string.state_collapsed_description)); } } } if (accessibilityHint != null) { - contentDescription.append(accessibilityHint + ", "); + contentDescription.add(accessibilityHint); } - if (contentDescription.length() > 0) { - view.setContentDescription(contentDescription.toString()); + if (contentDescription.size() > 0) { + view.setContentDescription(TextUtils.join(", ", contentDescription)); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java index 0924dacfc8e8cd..481f2e2821d669 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java @@ -18,12 +18,16 @@ import androidx.core.view.ViewCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; +import android.util.Log; import android.view.View; import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Dynamic; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.ReadableType; import com.facebook.react.bridge.WritableMap; import com.facebook.react.uimanager.events.RCTEventEmitter; import com.facebook.react.R; @@ -39,6 +43,7 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { + private static final String TAG = "ReactAccessibilityDelegate"; private static int sCounter = 0x3f000000; public static final HashMap sActionIdMap= new HashMap<>(); @@ -121,6 +126,12 @@ public static AccessibilityRole fromValue(@Nullable String value) { private final HashMap mAccessibilityActionsMap; + // State constants for states which have analogs in AccessibilityNodeInfo + + private static final String STATE_DISABLED = "disabled"; + private static final String STATE_SELECTED = "selected"; + private static final String STATE_CHECKED = "checked"; + public ReactAccessibilityDelegate() { super(); mAccessibilityActionsMap = new HashMap(); @@ -136,8 +147,12 @@ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCo // states are changable. final ReadableArray accessibilityStates = (ReadableArray) host.getTag(R.id.accessibility_states); + final ReadableMap accessibilityState = (ReadableMap) host.getTag(R.id.accessibility_state); if (accessibilityStates != null) { - setState(info, accessibilityStates, host.getContext()); + setStates(info, accessibilityStates, host.getContext()); + } + if (accessibilityState != null) { + setState(info, accessibilityState, host.getContext()); } final ReadableArray accessibilityActions = (ReadableArray) host.getTag(R.id.accessibility_actions); if (accessibilityActions != null) { @@ -175,7 +190,7 @@ public boolean performAccessibilityAction(View host, int action, Bundle args) { return super.performAccessibilityAction(host, action, args); } - public static void setState(AccessibilityNodeInfoCompat info, ReadableArray accessibilityStates, Context context) { + private static void setStates(AccessibilityNodeInfoCompat info, ReadableArray accessibilityStates, Context context) { for (int i = 0; i < accessibilityStates.size(); i++) { String state = accessibilityStates.getString(i); switch (state) { @@ -188,20 +203,38 @@ public static void setState(AccessibilityNodeInfoCompat info, ReadableArray acce case "checked": info.setCheckable(true); info.setChecked(true); - if (info.getClassName().equals("android.widget.Switch")) { + if (info.getClassName().equals(AccessibilityRole.getValue(AccessibilityRole.SWITCH))) { info.setText(context.getString(R.string.state_on_description)); } break; case "unchecked": info.setCheckable(true); info.setChecked(false); - if (info.getClassName().equals("android.widget.Switch")) { + if (info.getClassName().equals(AccessibilityRole.getValue(AccessibilityRole.SWITCH))) { info.setText(context.getString(R.string.state_off_description)); } break; - case "hasPopup": - info.setCanOpenPopup(true); - break; + } + } + } + + 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(); + final Dynamic value = accessibilityState.getDynamic(state); + if (state.equals(STATE_SELECTED) && value.getType() == ReadableType.Boolean) { + info.setSelected(value.asBoolean()); + } else if (state.equals(STATE_DISABLED) && value.getType() == ReadableType.Boolean) { + info.setEnabled(!value.asBoolean()); + } else if (state.equals(STATE_CHECKED) && value.getType() == ReadableType.Boolean) { + final boolean boolValue = value.asBoolean(); + info.setCheckable(true); + info.setChecked(boolValue); + if (info.getClassName().equals(AccessibilityRole.getValue(AccessibilityRole.SWITCH))) { + info.setText(context.getString(boolValue ? R.string.state_on_description : R.string.state_off_description)); + } } } } @@ -281,6 +314,7 @@ public static void setDelegate(final View view) { if (!ViewCompat.hasAccessibilityDelegate(view) && (view.getTag(R.id.accessibility_role) != null || view.getTag(R.id.accessibility_states) != null || + view.getTag(R.id.accessibility_state) != null || view.getTag(R.id.accessibility_actions) != null)) { ViewCompat.setAccessibilityDelegate(view, new ReactAccessibilityDelegate()); } diff --git a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 0ae5d684021781..6dce677f736dca 100644 --- a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -18,6 +18,9 @@ + + + diff --git a/ReactAndroid/src/main/res/views/uimanager/values/strings_unlocalized.xml b/ReactAndroid/src/main/res/views/uimanager/values/strings_unlocalized.xml index 7950f24a6a7b07..cd3cc03e0d6236 100644 --- a/ReactAndroid/src/main/res/views/uimanager/values/strings_unlocalized.xml +++ b/ReactAndroid/src/main/res/views/uimanager/values/strings_unlocalized.xml @@ -96,4 +96,8 @@ name="state_off_description" translatable="false" >off + mixed