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 61441eb5af2..3caf05f6bac 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 1010be60870..e9937dfd70a 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 b9e552937b4..5d733edb0a2 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 1bd0d27cab2..1f10ff412bd 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 235d47ff9af..adc6439584a 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 887a9805309..6323cee0ed9 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 b4a3b69040e..92837b4bed1 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 0a65d018820..d723f2797e7 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 6f40d3d3c78..711b57a77d6 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 861f105b455..e3a53b8e84d 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 01f035858cb..343513b5cf3 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 2e55b7c4fa6..21951fe145c 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 c5ae027942e..a12602c5291 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 39cdcaa974a..8f550ab57cb 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -222,6 +222,15 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused) 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 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused) @"busy" : @"busy", @"expanded" : @"expanded", @"collapsed" : @"collapsed", + @"mixed": @"mixed", }; }); NSString *roleDescription = self.accessibilityRole ? roleDescriptions[self.accessibilityRole]: nil; @@ -267,8 +277,27 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused) [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 7c538ed2e20..bdc9ecccd84 100644 --- a/React/Views/RCTViewManager.m +++ b/React/Views/RCTViewManager.m @@ -206,6 +206,38 @@ RCT_CUSTOM_VIEW_PROPERTY(accessibilityStates, NSArray, RCTView) } } +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 c4cab30b60a..6fbf47a0739 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 019eebcc120..d9c4d4190ab 100644 --- a/React/Views/UIView+React.m +++ b/React/Views/UIView+React.m @@ -327,6 +327,16 @@ 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 0d63d86cb1f..94bfdbf91fc 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.annotations.ReactProp; 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 abstract class BaseViewManager 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 0924dacfc8e..481f2e2821d 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.AccessibilityDelegateCompat; 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 @@ import javax.annotation.Nullable; 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 class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { 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 class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { // 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 class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { 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 class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { 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 class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { 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 0ae5d684021..6dce677f736 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 7950f24a6a7..cd3cc03e0d6 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