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 @@
+
+
+