diff --git a/Examples/UIExplorer/js/AccessibilityAndroidExample.android.js b/Examples/UIExplorer/js/AccessibilityAndroidExample.android.js index d2097a96aa2..3cfc43a4977 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 00000000000..47afbe2a3ed --- /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 00000000000..425c40e3437 --- /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 9f7a1cc121a..0c5f44e4c26 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 00000000000..765c607167a --- /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 00000000000..38b9d22aa99 --- /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 3e6e9606117..a928e6688d1 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 db3473a18c1..a47ecb32626 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 caed694ce35..3902b36f9e8 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 3024c8b68c2..9bf0fa50fab 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',