diff --git a/Libraries/Components/View/ReactNativeViewAttributes.js b/Libraries/Components/View/ReactNativeViewAttributes.js index 5d0ae6d3251666..50438910f2430f 100644 --- a/Libraries/Components/View/ReactNativeViewAttributes.js +++ b/Libraries/Components/View/ReactNativeViewAttributes.js @@ -20,6 +20,7 @@ const UIView = { accessibilityLiveRegion: true, accessibilityRole: true, accessibilityState: true, + accessibilityValue: true, accessibilityHint: true, importantForAccessibility: true, nativeID: true, diff --git a/Libraries/Components/View/ReactNativeViewViewConfig.js b/Libraries/Components/View/ReactNativeViewViewConfig.js index 32d40459537f58..e6fdf8e58d9cdf 100644 --- a/Libraries/Components/View/ReactNativeViewViewConfig.js +++ b/Libraries/Components/View/ReactNativeViewViewConfig.js @@ -123,6 +123,7 @@ const ReactNativeViewConfig = { accessibilityRole: true, accessibilityStates: true, // TODO: Can be removed after next release accessibilityState: true, + accessibilityValue: true, accessibilityViewIsModal: true, accessible: true, alignContent: true, diff --git a/Libraries/Components/View/ViewAccessibility.js b/Libraries/Components/View/ViewAccessibility.js index ef7b2996ffbb9b..ef003c40704689 100644 --- a/Libraries/Components/View/ViewAccessibility.js +++ b/Libraries/Components/View/ViewAccessibility.js @@ -62,3 +62,25 @@ export type AccessibilityState = { busy?: boolean, expanded?: boolean, }; + +export type AccessibilityValue = $ReadOnly<{| + /** + * The minimum value of this component's range. (should be an integer) + */ + min?: number, + + /** + * The maximum value of this component's range. (should be an integer) + */ + max?: number, + + /** + * The current value of this component's range. (should be an integer) + */ + now?: number, + + /** + * A textual description of this component's value. (will override minimum, current, and maximum if set) + */ + text?: string, +|}>; diff --git a/Libraries/Components/View/ViewPropTypes.js b/Libraries/Components/View/ViewPropTypes.js index 91b842f1b2ea0f..2080979a2c5e1d 100644 --- a/Libraries/Components/View/ViewPropTypes.js +++ b/Libraries/Components/View/ViewPropTypes.js @@ -18,6 +18,7 @@ import type {TVViewProps} from '../AppleTV/TVViewPropTypes'; import type { AccessibilityRole, AccessibilityState, + AccessibilityValue, AccessibilityActionEvent, AccessibilityActionInfo, } from './ViewAccessibility'; @@ -413,6 +414,7 @@ export type ViewProps = $ReadOnly<{| * Indicates to accessibility services that UI Component is in a specific State. */ accessibilityState?: ?AccessibilityState, + accessibilityValue?: ?AccessibilityValue, /** * Provides an array of custom actions available for accessibility. diff --git a/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js b/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js index 4ad60c5389aaff..8cb8ceddfb20fc 100644 --- a/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js +++ b/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js @@ -102,6 +102,7 @@ module.exports = { >), accessibilityState: PropTypes.object, + accessibilityValue: PropTypes.object, /** * Indicates to accessibility services whether the user should be notified * when this view changes. Works for Android API >= 19 only. diff --git a/RNTester/js/examples/Accessibility/AccessibilityExample.js b/RNTester/js/examples/Accessibility/AccessibilityExample.js index 39ba0ab1bc3ae4..e910fcf02c560b 100644 --- a/RNTester/js/examples/Accessibility/AccessibilityExample.js +++ b/RNTester/js/examples/Accessibility/AccessibilityExample.js @@ -512,6 +512,89 @@ class AccessibilityActionsExample extends React.Component { ); } } + +class FakeSliderExample extends React.Component { + state = { + current: 50, + textualValue: 'center', + }; + + increment = () => { + let newValue = this.state.current + 2; + if (newValue > 100) { + newValue = 100; + } + this.setState({ + current: newValue, + }); + }; + + decrement = () => { + let newValue = this.state.current - 2; + if (newValue < 0) { + newValue = 0; + } + this.setState({ + current: newValue, + }); + }; + + render() { + return ( + + { + switch (event.nativeEvent.actionName) { + case 'increment': + this.increment(); + break; + case 'decrement': + this.decrement(); + break; + } + }} + accessibilityValue={{ + min: 0, + now: this.state.current, + max: 100, + }}> + Fake Slider + + { + switch (event.nativeEvent.actionName) { + case 'increment': + if (this.state.textualValue === 'center') { + this.setState({textualValue: 'right'}); + } else if (this.state.textualValue === 'left') { + this.setState({textualValue: 'center'}); + } + break; + case 'decrement': + if (this.state.textualValue === 'center') { + this.setState({textualValue: 'left'}); + } else if (this.state.textualValue === 'right') { + this.setState({textualValue: 'center'}); + } + break; + } + }} + accessibilityValue={{text: this.state.textualValue}}> + Equalizer + + + ); + } +} + class ScreenReaderStatusExample extends React.Component<{}> { state = { screenReaderEnabled: false, @@ -591,6 +674,12 @@ exports.examples = [ return ; }, }, + { + title: 'Fake Slider Example', + render(): React.Element { + return ; + }, + }, { title: 'Check if the screen reader is enabled', render(): React.Element { diff --git a/React/Views/RCTView.m b/React/Views/RCTView.m index e7865f4ea8e416..579c2d17a0b520 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -271,6 +271,26 @@ - (NSString *)accessibilityValue [valueComponents addObject:stateDescriptions[@"busy"]]; } } + + // handle accessibilityValue + + if (self.accessibilityValueInternal) { + id min = self.accessibilityValueInternal[@"min"]; + id now = self.accessibilityValueInternal[@"now"]; + id max = self.accessibilityValueInternal[@"max"]; + id text = self.accessibilityValueInternal[@"text"]; + if (text && [text isKindOfClass:[NSString class]]) { + [valueComponents addObject:text]; + } else if ([min isKindOfClass:[NSNumber class]] && + [now isKindOfClass:[NSNumber class]] && + [max isKindOfClass:[NSNumber class]] && + ([min intValue] < [max intValue]) && + ([min intValue] <= [now intValue] && [now intValue] <= [max intValue])) { + int val = ([now intValue]*100)/([max intValue]-[min intValue]); + [valueComponents addObject:[NSString stringWithFormat:@"%d percent", val]]; + } + } + if (valueComponents.count > 0) { return [valueComponents componentsJoinedByString:@", "]; } diff --git a/React/Views/RCTViewManager.m b/React/Views/RCTViewManager.m index 53dd7b77ab4c6d..771caf6084c7f8 100644 --- a/React/Views/RCTViewManager.m +++ b/React/Views/RCTViewManager.m @@ -126,6 +126,7 @@ - (RCTShadowView *)shadowView RCT_REMAP_VIEW_PROPERTY(accessibilityActions, reactAccessibilityElement.accessibilityActions, NSDictionaryArray) RCT_REMAP_VIEW_PROPERTY(accessibilityLabel, reactAccessibilityElement.accessibilityLabel, NSString) RCT_REMAP_VIEW_PROPERTY(accessibilityHint, reactAccessibilityElement.accessibilityHint, NSString) +RCT_REMAP_VIEW_PROPERTY(accessibilityValue, reactAccessibilityElement.accessibilityValueInternal, NSDictionary) RCT_REMAP_VIEW_PROPERTY(accessibilityViewIsModal, reactAccessibilityElement.accessibilityViewIsModal, BOOL) RCT_REMAP_VIEW_PROPERTY(accessibilityElementsHidden, reactAccessibilityElement.accessibilityElementsHidden, BOOL) RCT_REMAP_VIEW_PROPERTY(accessibilityIgnoresInvertColors, reactAccessibilityElement.shouldAccessibilityIgnoresInvertColors, BOOL) diff --git a/React/Views/UIView+React.h b/React/Views/UIView+React.h index 5c855acb0a6a4c..adcaecd9b69e3c 100644 --- a/React/Views/UIView+React.h +++ b/React/Views/UIView+React.h @@ -119,6 +119,7 @@ @property (nonatomic, copy) NSString *accessibilityRole; @property (nonatomic, copy) NSDictionary *accessibilityState; @property (nonatomic, copy) NSArray *accessibilityActions; +@property (nonatomic, copy) NSDictionary *accessibilityValueInternal; /** * Used in debugging to get a description of the view hierarchy rooted at diff --git a/React/Views/UIView+React.m b/React/Views/UIView+React.m index 6309bbe3b55341..cb839fdd4b1eda 100644 --- a/React/Views/UIView+React.m +++ b/React/Views/UIView+React.m @@ -327,8 +327,16 @@ - (void)setAccessibilityState:(NSDictionary *)accessibilityState objc_setAssociatedObject(self, @selector(accessibilityState), accessibilityState, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } -#pragma mark - Debug +- (NSDictionary *)accessibilityValueInternal +{ + return objc_getAssociatedObject(self, _cmd); +} +- (void)setAccessibilityValueInternal:(NSDictionary *)accessibilityValue +{ + objc_setAssociatedObject(self, @selector(accessibilityValueInternal), accessibilityValue, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} +#pragma mark - Debug - (void)react_addRecursiveDescriptionToString:(NSMutableString *)string atLevel:(NSUInteger)level { for (NSUInteger i = 0; i < level; i++) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 4198a41da70d22..9c8b35b8ea5500 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -179,6 +179,7 @@ private void updateViewContentDescription(@NonNull T view) { final ReadableMap accessibilityState = (ReadableMap) view.getTag(R.id.accessibility_state); final String accessibilityHint = (String) view.getTag(R.id.accessibility_hint); final List contentDescription = new ArrayList<>(); + final ReadableMap accessibilityValue = (ReadableMap) view.getTag(R.id.accessibility_value); if (accessibilityLabel != null) { contentDescription.add(accessibilityLabel); } @@ -205,6 +206,12 @@ private void updateViewContentDescription(@NonNull T view) { } } } + if (accessibilityValue != null && accessibilityValue.hasKey("text")) { + final Dynamic text = accessibilityValue.getDynamic("text"); + if (text != null && text.getType() == ReadableType.String) { + contentDescription.add(text.asString()); + } + } if (accessibilityHint != null) { contentDescription.add(accessibilityHint); } @@ -223,6 +230,18 @@ public void setAccessibilityActions(T view, ReadableArray accessibilityActions) view.setTag(R.id.accessibility_actions, accessibilityActions); } + @ReactProp(name = ViewProps.ACCESSIBILITY_VALUE) + public void setAccessibilityValue(T view, ReadableMap accessibilityValue) { + if (accessibilityValue == null) { + return; + } + + view.setTag(R.id.accessibility_value, accessibilityValue); + if (accessibilityValue.hasKey("text")) { + updateViewContentDescription(view); + } + } + @Override @ReactProp(name = ViewProps.IMPORTANT_FOR_ACCESSIBILITY) public void setImportantForAccessibility( 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 a301aa0d8d8c04..1acb3b83d7980a 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java @@ -7,15 +7,18 @@ import android.content.Context; import android.os.Bundle; +import android.os.Handler; +import android.os.Message; import android.text.SpannableString; import android.text.style.URLSpan; -import android.util.Log; import android.view.View; +import android.view.accessibility.AccessibilityEvent; import androidx.annotation.Nullable; import androidx.core.view.AccessibilityDelegateCompat; import androidx.core.view.ViewCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat; import com.facebook.react.R; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Dynamic; @@ -36,6 +39,8 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { private static final String TAG = "ReactAccessibilityDelegate"; private static int sCounter = 0x3f000000; + private static final int TIMEOUT_SEND_ACCESSIBILITY_EVENT = 200; + private static final int SEND_EVENT = 1; public static final HashMap sActionIdMap = new HashMap<>(); @@ -46,6 +51,23 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { sActionIdMap.put("decrement", AccessibilityActionCompat.ACTION_SCROLL_BACKWARD.getId()); } + private Handler mHandler; + + /** + * Schedule a command for sending an accessibility event. + *
+ * Note: A command is used to ensure that accessibility events + * are sent at most one in a given time frame to save + * system resources while the progress changes quickly. + */ + private void scheduleAccessibilityEventSender(View host) { + if (mHandler.hasMessages(SEND_EVENT, host)) { + mHandler.removeMessages(SEND_EVENT, host); + } + Message msg = mHandler.obtainMessage(SEND_EVENT, host); + mHandler.sendMessageDelayed(msg, TIMEOUT_SEND_ACCESSIBILITY_EVENT); + } + /** * These roles are defined by Google's TalkBack screen reader, and this list should be kept up to * date with their implementation. Details can be seen in their source code here: @@ -148,6 +170,13 @@ public static AccessibilityRole fromValue(@Nullable String value) { public ReactAccessibilityDelegate() { super(); mAccessibilityActionsMap = new HashMap(); + mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + View host = (View) msg.obj; + host.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); + } + }; } @Override @@ -185,6 +214,53 @@ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCo info.addAction(accessibilityAction); } } + + // Process accessibilityValue + + final ReadableMap accessibilityValue = (ReadableMap) host.getTag(R.id.accessibility_value); + if (accessibilityValue != null && accessibilityValue.hasKey("min") + && accessibilityValue.hasKey("now") && accessibilityValue.hasKey("max")) { + final Dynamic minDynamic = accessibilityValue.getDynamic("min"); + final Dynamic nowDynamic = accessibilityValue.getDynamic("now"); + final Dynamic maxDynamic = accessibilityValue.getDynamic("max"); + if (minDynamic != null && minDynamic.getType() == ReadableType.Number && + nowDynamic != null && nowDynamic.getType() == ReadableType.Number && + maxDynamic != null && maxDynamic.getType() == ReadableType.Number) { + final int min = minDynamic.asInt(); + final int now = nowDynamic.asInt(); + final int max = maxDynamic.asInt(); + if (max > min && + now >= min && max >= now) { + info.setRangeInfo(RangeInfoCompat.obtain(RangeInfoCompat.RANGE_TYPE_INT, min, max, now)); + } + } + } + } + + @Override + public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(host, event); + // Set item count and current item index on accessibility events for adjustable + // in order to make Talkback announce the value of the adjustable + final ReadableMap accessibilityValue = (ReadableMap) host.getTag(R.id.accessibility_value); + if (accessibilityValue != null && accessibilityValue.hasKey("min") + && accessibilityValue.hasKey("now") && accessibilityValue.hasKey("max")) { + final Dynamic minDynamic = accessibilityValue.getDynamic("min"); + final Dynamic nowDynamic = accessibilityValue.getDynamic("now"); + final Dynamic maxDynamic = accessibilityValue.getDynamic("max"); + if (minDynamic != null && minDynamic.getType() == ReadableType.Number && + nowDynamic != null && nowDynamic.getType() == ReadableType.Number && + maxDynamic != null && maxDynamic.getType() == ReadableType.Number) { + final int min = minDynamic.asInt(); + final int now = nowDynamic.asInt(); + final int max = maxDynamic.asInt(); + if (max > min && + now >= min && max >= now) { + event.setItemCount(max - min); + event.setCurrentItemIndex(now); + } + } + } } @Override @@ -196,6 +272,19 @@ public boolean performAccessibilityAction(View host, int action, Bundle args) { reactContext .getJSModule(RCTEventEmitter.class) .receiveEvent(host.getId(), "topAccessibilityAction", event); + + // In order to make Talkback announce the change of the adjustable's value, + // schedule to send a TYPE_VIEW_SELECTED event after performing the scroll actions. + final AccessibilityRole accessibilityRole = (AccessibilityRole) host.getTag(R.id.accessibility_role); + final ReadableMap accessibilityValue = (ReadableMap) host.getTag(R.id.accessibility_value); + if (accessibilityRole == AccessibilityRole.ADJUSTABLE + && (action == AccessibilityActionCompat.ACTION_SCROLL_FORWARD.getId() + || action == AccessibilityActionCompat.ACTION_SCROLL_BACKWARD.getId())) { + if (accessibilityValue != null && !accessibilityValue.hasKey("text")) { + scheduleAccessibilityEventSender(host); + } + return super.performAccessibilityAction(host, action, args); + } return true; } return super.performAccessibilityAction(host, action, args); @@ -203,7 +292,6 @@ public boolean performAccessibilityAction(View host, int action, Bundle args) { private static void setState( AccessibilityNodeInfoCompat info, ReadableMap accessibilityState, Context context) { - Log.d(TAG, "setState " + accessibilityState); final ReadableMapKeySetIterator i = accessibilityState.keySetIterator(); while (i.hasNextKey()) { final String state = i.nextKey(); diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java index 72ebb65854664b..41d9ed50a19118 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java @@ -141,6 +141,7 @@ public class ViewProps { public static final String ACCESSIBILITY_ROLE = "accessibilityRole"; public static final String ACCESSIBILITY_STATE = "accessibilityState"; public static final String ACCESSIBILITY_ACTIONS = "accessibilityActions"; + public static final String ACCESSIBILITY_VALUE = "accessibilityValue"; public static final String IMPORTANT_FOR_ACCESSIBILITY = "importantForAccessibility"; // DEPRECATED diff --git a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index ff82dd20c479d5..6886defd469257 100644 --- a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -24,4 +24,7 @@ + + +