diff --git a/Libraries/Components/View/ReactNativeViewAttributes.js b/Libraries/Components/View/ReactNativeViewAttributes.js index 6c2b211d4cbb41..cdae72a85ef8fe 100644 --- a/Libraries/Components/View/ReactNativeViewAttributes.js +++ b/Libraries/Components/View/ReactNativeViewAttributes.js @@ -23,6 +23,7 @@ ReactNativeViewAttributes.UIView = { accessibilityLiveRegion: true, accessibilityRole: true, accessibilityState: true, + accessibilityValue: true, importantForAccessibility: true, nativeID: true, testID: true, diff --git a/Libraries/Components/View/ViewAccessibility.js b/Libraries/Components/View/ViewAccessibility.js index 009e9fc3eaa1d5..9e96d5fc5b102f 100644 --- a/Libraries/Components/View/ViewAccessibility.js +++ b/Libraries/Components/View/ViewAccessibility.js @@ -63,3 +63,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 da9b14e898b942..41d6521b467856 100644 --- a/Libraries/Components/View/ViewPropTypes.js +++ b/Libraries/Components/View/ViewPropTypes.js @@ -21,6 +21,7 @@ import type {TVViewProps} from 'TVViewPropTypes'; import type { AccessibilityRole, AccessibilityState, + AccessibilityValue, AccessibilityActionEvent, AccessibilityActionInfo, } from './ViewAccessibility'; @@ -418,6 +419,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 11369be0dc56ac..a6386f9f0ec8da 100644 --- a/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js +++ b/Libraries/DeprecatedPropTypes/DeprecatedViewPropTypes.js @@ -60,6 +60,7 @@ module.exports = { accessibilityRole: PropTypes.oneOf(DeprecatedAccessibilityRoles), 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/AccessibilityExample.js b/RNTester/js/AccessibilityExample.js index 909f594a949cd7..6b7c306e9b560c 100644 --- a/RNTester/js/AccessibilityExample.js +++ b/RNTester/js/AccessibilityExample.js @@ -513,6 +513,88 @@ 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, @@ -592,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 08d0cabed49042..9126767b8770a2 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -273,6 +273,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 5d277bb0edb1f6..4fbbd1b3cf8692 100644 --- a/React/Views/RCTViewManager.m +++ b/React/Views/RCTViewManager.m @@ -139,6 +139,7 @@ - (RCTViewManagerUIBlock)uiBlockToAmendWithShadowViewRegistry:(__unused NSDictio RCT_REMAP_VIEW_PROPERTY(accessible, reactAccessibilityElement.isAccessibilityElement, BOOL) RCT_REMAP_VIEW_PROPERTY(accessibilityActions, reactAccessibilityElement.accessibilityActions, NSDictionaryArray) RCT_REMAP_VIEW_PROPERTY(accessibilityLabel, reactAccessibilityElement.accessibilityLabel, NSString) +RCT_REMAP_VIEW_PROPERTY(accessibilityValue, reactAccessibilityElement.accessibilityValueInternal, NSDictionary) RCT_REMAP_VIEW_PROPERTY(accessibilityViewIsModal, reactAccessibilityElement.accessibilityViewIsModal, BOOL) RCT_REMAP_VIEW_PROPERTY(accessibilityIgnoresInvertColors, reactAccessibilityElement.shouldAccessibilityIgnoresInvertColors, BOOL) RCT_REMAP_VIEW_PROPERTY(onAccessibilityAction, reactAccessibilityElement.onAccessibilityAction, RCTDirectEventBlock) diff --git a/React/Views/UIView+React.h b/React/Views/UIView+React.h index bf5972ad3184a4..4568d443e3d52f 100644 --- a/React/Views/UIView+React.h +++ b/React/Views/UIView+React.h @@ -120,6 +120,7 @@ @property (nonatomic, copy) NSString *accessibilityRole; @property (nonatomic, copy) NSDictionary *accessibilityState; @property (nonatomic, copy) NSArray *accessibilityActions; +@property (nonatomic, copy) NSDictionary *accessibilityValueInternal; #if RCT_DEV diff --git a/React/Views/UIView+React.m b/React/Views/UIView+React.m index bab12f9a3106b6..47f8b2d441dde3 100644 --- a/React/Views/UIView+React.m +++ b/React/Views/UIView+React.m @@ -344,4 +344,13 @@ - (void)setAccessibilityState:(NSDictionary *)accessibilityState objc_setAssociatedObject(self, @selector(accessibilityState), accessibilityState, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } +- (NSDictionary *)accessibilityValueInternal +{ + return objc_getAssociatedObject(self, _cmd); +} +- (void)setAccessibilityValueInternal:(NSDictionary *)accessibilityValue +{ + objc_setAssociatedObject(self, @selector(accessibilityValueInternal), accessibilityValue, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + @end 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 6c3bd19f1a0275..065e5efedb893a 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -7,9 +7,8 @@ import android.os.Build; import android.view.View; import android.view.ViewParent; - -import java.util.ArrayList; -import java.util.HashMap; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import com.facebook.react.R; import com.facebook.react.bridge.Dynamic; @@ -24,9 +23,10 @@ import com.facebook.react.uimanager.util.ReactFindViewUtil; import java.util.Locale; -import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; import java.util.Map; -import javax.annotation.Nullable; /** * Base class that should be suitable for the majority of subclasses of {@link ViewManager}. @@ -66,7 +66,7 @@ public abstract class BaseViewManager sStateDescription = new HashMap(); + public static final Map sStateDescription = new HashMap<>(); static { sStateDescription.put("busy", R.string.state_busy_description); @@ -115,7 +115,7 @@ public void setZIndex(T view, float zIndex) { int integerZIndex = Math.round(zIndex); ViewGroupManager.setViewZIndex(view, integerZIndex); ViewParent parent = view.getParent(); - if (parent != null && parent instanceof ReactZIndexedViewGroup) { + if (parent instanceof ReactZIndexedViewGroup) { ((ReactZIndexedViewGroup) parent).updateDrawingOrder(); } } @@ -180,7 +180,8 @@ public void setViewState(@Nonnull T view, @Nullable ReadableMap accessibilitySta private void updateViewContentDescription(@Nonnull T view) { final String accessibilityLabel = (String) view.getTag(R.id.accessibility_label); final ReadableMap accessibilityState = (ReadableMap) view.getTag(R.id.accessibility_state); - final ArrayList contentDescription = new ArrayList(); + final List contentDescription = new ArrayList<>(); + final ReadableMap accessibilityValue = (ReadableMap) view.getTag(R.id.accessibility_value); if (accessibilityLabel != null) { contentDescription.add(accessibilityLabel); } @@ -198,6 +199,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 (contentDescription.size() > 0) { view.setContentDescription(TextUtils.join(", ", contentDescription)); } @@ -212,6 +219,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); + } + } + @ReactProp(name = PROP_IMPORTANT_FOR_ACCESSIBILITY) public void setImportantForAccessibility( @Nonnull T view, @Nullable String importantForAccessibility) { 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 6483434c301ae1..55e98b7d48e872 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java @@ -5,17 +5,20 @@ package com.facebook.react.uimanager; -import android.os.Bundle; 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.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.support.annotation.Nullable; import android.support.v4.view.AccessibilityDelegateCompat; import android.support.v4.view.ViewCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; -import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; -import android.text.SpannableString; -import android.text.style.URLSpan; -import android.util.Log; -import android.view.View; +import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Dynamic; @@ -29,8 +32,6 @@ import com.facebook.react.R; import java.util.HashMap; -import java.util.Locale; -import javax.annotation.Nullable; /** * Utility class that handles the addition of a "role" for accessibility to @@ -41,6 +42,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<>(); static { @@ -50,6 +53,21 @@ 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 @@ -60,9 +78,33 @@ public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { */ public enum AccessibilityRole { - NONE, BUTTON, LINK, SEARCH, IMAGE, IMAGEBUTTON, KEYBOARDKEY, TEXT, ADJUSTABLE, SUMMARY, HEADER, ALERT, CHECKBOX, - COMBOBOX, MENU, MENUBAR, MENUITEM, PROGRESSBAR, RADIO, RADIOGROUP, SCROLLBAR, SPINBUTTON, - SWITCH, TAB, TABLIST, TIMER, TOOLBAR; + NONE, + BUTTON, + LINK, + SEARCH, + IMAGE, + IMAGEBUTTON, + KEYBOARDKEY, + TEXT, + ADJUSTABLE, + SUMMARY, + HEADER, + ALERT, + CHECKBOX, + COMBOBOX, + MENU, + MENUBAR, + MENUITEM, + PROGRESSBAR, + RADIO, + RADIOGROUP, + SCROLLBAR, + SPINBUTTON, + SWITCH, + TAB, + TABLIST, + TIMER, + TOOLBAR; public static String getValue(AccessibilityRole role) { switch (role) { @@ -131,6 +173,14 @@ 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 @@ -165,6 +215,61 @@ 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 @@ -173,17 +278,29 @@ public boolean performAccessibilityAction(View host, int action, Bundle args) { final WritableMap event = Arguments.createMap(); event.putString("actionName", mAccessibilityActionsMap.get(action)); ReactContext reactContext = (ReactContext)host.getContext(); - reactContext.getJSModule(RCTEventEmitter.class).receiveEvent( - host.getId(), - "topAccessibilityAction", - event); + 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); } 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 3ebe75d17f6d29..8db0f8b7e22e22 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java @@ -146,6 +146,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/java/com/facebook/react/views/view/ReactViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java index 0ca79f814e07fe..5015e485189785 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java @@ -13,6 +13,8 @@ import android.graphics.Rect; import android.os.Build; import android.view.View; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; @@ -30,7 +32,6 @@ import com.facebook.yoga.YogaConstants; import java.util.Locale; import java.util.Map; -import javax.annotation.Nullable; /** * View manager for AndroidViews (plain React Views). @@ -52,6 +53,7 @@ public class ReactViewManager extends ViewGroupManager { }; private static final int CMD_HOTSPOT_UPDATE = 1; private static final int CMD_SET_PRESSED = 2; + private static final String HOTSPOT_UPDATE_KEY = "hotspotUpdate"; @ReactProp(name = "accessible") public void setAccessible(ReactViewGroup view, boolean accessible) { @@ -209,7 +211,7 @@ public ReactViewGroup createViewInstance(ThemedReactContext context) { @Override public Map getCommandsMap() { - return MapBuilder.of("hotspotUpdate", CMD_HOTSPOT_UPDATE, "setPressed", CMD_SET_PRESSED); + return MapBuilder.of(HOTSPOT_UPDATE_KEY, CMD_HOTSPOT_UPDATE, "setPressed", CMD_SET_PRESSED); } @Override diff --git a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 84aa681100f86a..a8316545c63bc5 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 @@ + + +