diff --git a/Examples/UIExplorer/js/AccessibilityAndroidExample.android.js b/Examples/UIExplorer/js/AccessibilityAndroidExample.android.js index d2097a96a..3cfc43a49 100644 --- a/Examples/UIExplorer/js/AccessibilityAndroidExample.android.js +++ b/Examples/UIExplorer/js/AccessibilityAndroidExample.android.js @@ -25,6 +25,7 @@ var React = require('react'); var ReactNative = require('react-native'); var { + AccessibilityInfo, StyleSheet, Text, View, @@ -45,8 +46,34 @@ class AccessibilityAndroidExample extends React.Component { count: 0, backgroundImportantForAcc: 0, forgroundImportantForAcc: 0, + screenReaderEnabled: false, }; + componentDidMount() { + AccessibilityInfo.addEventListener( + 'change', + this._handleScreenReaderToggled + ); + AccessibilityInfo.fetch().done((isEnabled) => { + this.setState({ + screenReaderEnabled: isEnabled + }); + }); + } + + componentWillUnmount() { + AccessibilityInfo.removeEventListener( + 'change', + this._handleScreenReaderToggled + ); + } + + _handleScreenReaderToggled = (isEnabled) => { + this.setState({ + screenReaderEnabled: isEnabled, + }); + } + _addOne = () => { this.setState({ count: ++this.state.count, @@ -125,6 +152,12 @@ class AccessibilityAndroidExample extends React.Component { + + + The screen reader is {this.state.screenReaderEnabled ? 'enabled' : 'disabled'}. + + + { + this.setState({ + screenReaderEnabled: isEnabled + }); + }); + } + + componentWillUnmount() { + AccessibilityInfo.removeEventListener( + 'change', + this._handleScreenReaderToggled + ); + } + + _handleScreenReaderToggled = (isEnabled) => { + this.setState({ + screenReaderEnabled: isEnabled, + }); + } + + render() { + return ( + + + The screen reader is {this.state.screenReaderEnabled ? 'enabled' : 'disabled'}. + + + ); + } +} + exports.title = 'AccessibilityIOS'; exports.description = 'Interface to show iOS\' accessibility samples'; exports.examples = [ @@ -71,4 +113,8 @@ exports.examples = [ title: 'Accessibility elements', render(): React.Element { return ; } }, + { + title: 'Check if the screen reader is enabled', + render(): React.Element { return ; } + }, ]; diff --git a/Libraries/Components/AccessibilityInfo/AccessibilityInfo.android.js b/Libraries/Components/AccessibilityInfo/AccessibilityInfo.android.js new file mode 100644 index 000000000..47afbe2a3 --- /dev/null +++ b/Libraries/Components/AccessibilityInfo/AccessibilityInfo.android.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule AccessibilityInfo + * @flow + */ +'use strict'; + +var NativeModules = require('NativeModules'); +var RCTDeviceEventEmitter = require('RCTDeviceEventEmitter'); + +var RCTAccessibilityInfo = NativeModules.AccessibilityInfo; + +var TOUCH_EXPLORATION_EVENT = 'touchExplorationDidChange'; + +type ChangeEventName = $Enum<{ + change: string, +}>; + +var _subscriptions = new Map(); + +var AccessibilityInfo = { + + fetch: function(): Promise { + return new Promise((resolve, reject) => { + RCTAccessibilityInfo.isTouchExplorationEnabled( + function(resp) { + resolve(resp); + } + ); + }); + }, + + addEventListener: function ( + eventName: ChangeEventName, + handler: Function + ): void { + var listener = RCTDeviceEventEmitter.addListener( + TOUCH_EXPLORATION_EVENT, + (enabled) => { + handler(enabled); + } + ); + _subscriptions.set(handler, listener); + }, + + removeEventListener: function( + eventName: ChangeEventName, + handler: Function + ): void { + var listener = _subscriptions.get(handler); + if (!listener) { + return; + } + listener.remove(); + _subscriptions.delete(handler); + }, + +}; + +module.exports = AccessibilityInfo; diff --git a/Libraries/Components/AccessibilityInfo/AccessibilityInfo.ios.js b/Libraries/Components/AccessibilityInfo/AccessibilityInfo.ios.js new file mode 100644 index 000000000..425c40e34 --- /dev/null +++ b/Libraries/Components/AccessibilityInfo/AccessibilityInfo.ios.js @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule AccessibilityInfo + * @flow + */ +'use strict'; + +var NativeModules = require('NativeModules'); +var Promise = require('Promise'); +var RCTDeviceEventEmitter = require('RCTDeviceEventEmitter'); + +var AccessibilityManager = NativeModules.AccessibilityManager; + +var VOICE_OVER_EVENT = 'voiceOverDidChange'; + +type ChangeEventName = $Enum<{ + change: string, +}>; + +var _subscriptions = new Map(); + +/** + * Sometimes it's useful to know whether or not the device has a screen reader that is currently active. The + * `AccessibilityInfo` API is designed for this purpose. You can use it to query the current state of the + * screen reader as well as to register to be notified when the state of the screen reader changes. + * + * Here's a small example illustrating how to use `AccessibilityInfo`: + * + * ```javascript + * class ScreenReaderStatusExample extends React.Component { + * state = { + * screenReaderEnabled: false, + * } + * + * componentDidMount() { + * AccessibilityInfo.addEventListener( + * 'change', + * this._handleScreenReaderToggled + * ); + * AccessibilityInfo.fetch().done((isEnabled) => { + * this.setState({ + * screenReaderEnabled: isEnabled + * }); + * }); + * } + * + * componentWillUnmount() { + * AccessibilityInfo.removeEventListener( + * 'change', + * this._handleScreenReaderToggled + * ); + * } + * + * _handleScreenReaderToggled = (isEnabled) => { + * this.setState({ + * screenReaderEnabled: isEnabled, + * }); + * } + * + * render() { + * return ( + * + * + * The screen reader is {this.state.screenReaderEnabled ? 'enabled' : 'disabled'}. + * + * + * ); + * } + * } + * ``` + */ +var AccessibilityInfo = { + + /** + * Query whether a screen reader is currently enabled. Returns a promise which + * resolves to a boolean. The result is `true` when a screen reader is enabled + * and `false` otherwise. + */ + fetch: function(): Promise { + return new Promise((resolve, reject) => { + AccessibilityManager.getCurrentVoiceOverState( + resolve, + reject + ); + }); + }, + + /** + * Add an event handler. Supported events: + * + * - `change`: Fires when the state of the screen reader changes. The argument + * to the event handler is a boolean. The boolean is `true` when a screen + * reader is enabled and `false` otherwise. + */ + addEventListener: function ( + eventName: ChangeEventName, + handler: Function + ): Object { + var listener = RCTDeviceEventEmitter.addListener( + VOICE_OVER_EVENT, + handler + ); + _subscriptions.set(handler, listener); + return { + remove: AccessibilityInfo.removeEventListener.bind(null, eventName, handler), + }; + }, + + /** + * Remove an event handler. + */ + removeEventListener: function( + eventName: ChangeEventName, + handler: Function + ): void { + var listener = _subscriptions.get(handler); + if (!listener) { + return; + } + listener.remove(); + _subscriptions.delete(handler); + }, + +}; + +module.exports = AccessibilityInfo; diff --git a/Libraries/react-native/react-native-implementation.js b/Libraries/react-native/react-native-implementation.js index 9f7a1cc12..0c5f44e4c 100644 --- a/Libraries/react-native/react-native-implementation.js +++ b/Libraries/react-native/react-native-implementation.js @@ -28,6 +28,7 @@ if (__DEV__) { // Export React, plus some native additions. const ReactNative = { // Components + get AccessibilityInfo() { return require('AccessibilityInfo'); }, get ActivityIndicator() { return require('ActivityIndicator'); }, get ART() { return require('ReactNativeART'); }, get Button() { return require('Button'); }, diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.java new file mode 100644 index 000000000..765c60716 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/AccessibilityInfoModule.java @@ -0,0 +1,98 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.modules.accessibilityinfo; + +import javax.annotation.Nullable; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.view.accessibility.AccessibilityManager; + +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.modules.core.DeviceEventManagerModule; + +/** + * Module that monitors and provides information about the state of Touch Exploration service + * on the device. For API >= 19. + */ +@ReactModule(name = "AccessibilityInfo") +public class AccessibilityInfoModule extends ReactContextBaseJavaModule + implements LifecycleEventListener { + + @TargetApi(19) + private class ReactTouchExplorationStateChangeListener + implements AccessibilityManager.TouchExplorationStateChangeListener { + + @Override + public void onTouchExplorationStateChanged(boolean enabled) { + updateAndSendChangeEvent(enabled); + } + } + + private @Nullable AccessibilityManager mAccessibilityManager; + private @Nullable ReactTouchExplorationStateChangeListener mTouchExplorationStateChangeListener; + private boolean mEnabled = false; + + private static final String EVENT_NAME = "touchExplorationDidChange"; + + public AccessibilityInfoModule(ReactApplicationContext context) { + super(context); + mAccessibilityManager = (AccessibilityManager) getReactApplicationContext() + .getSystemService(Context.ACCESSIBILITY_SERVICE); + mEnabled = mAccessibilityManager.isTouchExplorationEnabled(); + if (Build.VERSION.SDK_INT >= 19) { + mTouchExplorationStateChangeListener = new ReactTouchExplorationStateChangeListener(); + } + } + + @Override + public String getName() { + return "AccessibilityInfo"; + } + + @ReactMethod + public void isTouchExplorationEnabled(Callback successCallback) { + successCallback.invoke(mEnabled); + } + + private void updateAndSendChangeEvent(boolean enabled) { + if (mEnabled != enabled) { + mEnabled = enabled; + getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(EVENT_NAME, mEnabled); + } + } + + @Override + public void onHostResume() { + if (Build.VERSION.SDK_INT >= 19) { + mAccessibilityManager.addTouchExplorationStateChangeListener( + mTouchExplorationStateChangeListener); + } + updateAndSendChangeEvent(mAccessibilityManager.isTouchExplorationEnabled()); + } + + @Override + public void onHostPause() { + if (Build.VERSION.SDK_INT >= 19) { + mAccessibilityManager.removeTouchExplorationStateChangeListener( + mTouchExplorationStateChangeListener); + } + } + + @Override + public void initialize() { + getReactApplicationContext().addLifecycleEventListener(this); + updateAndSendChangeEvent(mAccessibilityManager.isTouchExplorationEnabled()); + } + + @Override + public void onHostDestroy() { + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/BUCK b/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/BUCK new file mode 100644 index 000000000..38b9d22aa --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/accessibilityinfo/BUCK @@ -0,0 +1,22 @@ +include_defs("//ReactAndroid/DEFS") + +android_library( + name = "accessibilityinfo", + srcs = glob(["**/*.java"]), + visibility = [ + "PUBLIC", + ], + deps = [ + react_native_dep("libraries/fbcore/src/main/java/com/facebook/common/logging:logging"), + react_native_dep("third-party/java/infer-annotations:infer-annotations"), + react_native_dep("third-party/java/jsr-305:jsr-305"), + react_native_target("java/com/facebook/react/bridge:bridge"), + react_native_target("java/com/facebook/react/common:common"), + react_native_target("java/com/facebook/react/module/annotations:annotations"), + react_native_target("java/com/facebook/react/modules/core:core"), + ], +) + +project_config( + src_target = ":accessibilityinfo", +) diff --git a/ReactAndroid/src/main/java/com/facebook/react/shell/BUCK b/ReactAndroid/src/main/java/com/facebook/react/shell/BUCK index 3e6e96061..a928e6688 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/shell/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/shell/BUCK @@ -19,6 +19,7 @@ android_library( react_native_target("java/com/facebook/react/devsupport:devsupport"), react_native_target("java/com/facebook/react/flat:flat"), react_native_target("java/com/facebook/react/module/model:model"), + react_native_target("java/com/facebook/react/modules/accessibilityinfo:accessibilityinfo"), react_native_target("java/com/facebook/react/modules/appstate:appstate"), react_native_target("java/com/facebook/react/modules/camera:camera"), react_native_target("java/com/facebook/react/modules/clipboard:clipboard"), diff --git a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java index db3473a18..a47ecb326 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java +++ b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java @@ -36,6 +36,7 @@ import com.facebook.react.flat.RCTViewManager; import com.facebook.react.flat.RCTViewPagerManager; import com.facebook.react.flat.RCTVirtualTextManager; import com.facebook.react.module.model.ReactModuleInfoProvider; +import com.facebook.react.modules.accessibilityinfo.AccessibilityInfoModule; import com.facebook.react.modules.appstate.AppStateModule; import com.facebook.react.modules.camera.CameraRollManager; import com.facebook.react.modules.camera.ImageEditingManager; @@ -102,6 +103,12 @@ public class MainReactPackage extends LazyReactPackage { @Override public List getNativeModules(final ReactApplicationContext context) { return Arrays.asList( + new ModuleSpec(AccessibilityInfoModule.class, new Provider() { + @Override + public NativeModule get() { + return new AccessibilityInfoModule(context); + } + }), new ModuleSpec(AppStateModule.class, new Provider() { @Override public NativeModule get() { diff --git a/docs/Accessibility.md b/docs/Accessibility.md index caed694ce..3902b36f9 100644 --- a/docs/Accessibility.md +++ b/docs/Accessibility.md @@ -11,6 +11,8 @@ previous: animations ## Native App Accessibility (iOS and Android) Both iOS and Android provide APIs for making apps accessible to people with disabilities. In addition, both platforms provide bundled assistive technologies, like the screen readers VoiceOver (iOS) and TalkBack (Android) for the visually impaired. Similarly, in React Native we have included APIs designed to provide developers with support for making apps more accessible. Take note, iOS and Android differ slightly in their approaches, and thus the React Native implementations may vary by platform. +In addition to this documentation, you might find [this blog post](https://code.facebook.com/posts/435862739941212/making-react-native-apps-accessible/) about React Native accessibility to be useful. + ## Making Apps Accessible ### Accessibility properties @@ -135,7 +137,9 @@ In the case of two overlapping UI components with the same parent, default acces In the above example, the yellow layout and its descendants are completely invisible to TalkBack and all other accessibility services. So we can easily use overlapping views with the same parent without confusing TalkBack. +### Checking if a Screen Reader is Enabled +The `AccessibilityInfo` API allows you to determine whether or not a screen reader is currently active. See the [AccessibilityInfo documentation](docs/accessibilityinfo.html) for details. ### Sending Accessibility Events (Android) diff --git a/website/server/docsList.js b/website/server/docsList.js index 3024c8b68..9bf0fa50f 100644 --- a/website/server/docsList.js +++ b/website/server/docsList.js @@ -47,6 +47,7 @@ const components = [ ]; const apis = [ + '../Libraries/Components/AccessibilityInfo/AccessibilityInfo.ios.js', '../Libraries/ActionSheetIOS/ActionSheetIOS.js', '../Libraries/AdSupport/AdSupportIOS.js', '../Libraries/Alert/Alert.js',