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