Skip to content
Permalink
Browse files

- Add support for "reduce motion" into AccessibilityInfo (#23839)

Summary:
This PR adds `isReduceMotionEnabled()` to `AccessibilityInfo` in other to add support for "reduce motion", exposing the Operational System's settings option. Additionally, it adds a new event, `reduceMotionChanged`, in order to listen for this flag's update.

With this feature, developers will be able to disable or reduce animations, _**something that will be required as soon as WCAG 2.1 draft got approved**._ See [WCAG 2.1 — 2.3.3 Animations from Interaction criteria](https://knowbility.org/blog/2018/WCAG21-233Animations/)

It's exposed by [`UIAccessibility`' isReduceMotionEnabled ](https://developer.apple.com/documentation/uikit/uiaccessibility/1615133-isreducemotionenabled
) on iOS and [Settings.Global.TRANSITION_ANIMATION_SCALE](https://developer.android.com/reference/android/provider/Settings.Global#TRANSITION_ANIMATION_SCALE) on Android.

Up until now, `AccessibilityInfo` only exposes screen reader flag. By adding this second accessibility option, it's a good opportunity to rename `fetch` method to an appropriate name, `isScreenReaderEnabled`, as well as rename `change` event to `screenReaderChanged`, which will make it clearer and more specific.

(In case it's approved, a follow-up PR could exposes [more iOS acessibility flags](https://developer.apple.com/documentation/uikit/uiaccessibility), such as `isShakeToUndoEnabled`, `isReduceTransparencyEnabled`, `isGrayscaleEnabled`, `isInvertColorsEnabled`)

(iOS code inspired by [phonegap-mobile-accessibility](https://github.com/phonegap/phonegap-mobile-accessibility). And Android by [Flutter](https://github.com/flutter/engine/blob/master/shell/platform/android/io/flutter/view/AccessibilityBridge.java
))
Pull Request resolved: #23839

Differential Revision: D14406227

Pulled By: hramos

fbshipit-source-id: adf43be84c488522bf1e29d862681220ad193883
  • Loading branch information...
estevaolucas authored and facebook-github-bot committed Mar 13, 2019
1 parent 8e490d4 commit 0090ab32c2aeffed76ff58931930fe40a45e6ebc
@@ -16,10 +16,13 @@ const UIManager = require('UIManager');

const RCTAccessibilityInfo = NativeModules.AccessibilityInfo;

const REDUCE_MOTION_EVENT = 'reduceMotionDidChange';
const TOUCH_EXPLORATION_EVENT = 'touchExplorationDidChange';

type ChangeEventName = $Enum<{
change: string,
reduceMotionChanged: string,
screenReaderChanged: string,
}>;

const _subscriptions = new Map();
@@ -35,26 +38,49 @@ const _subscriptions = new Map();
*/

const AccessibilityInfo = {
/* $FlowFixMe(>=0.78.0 site=react_native_android_fb) This issue was found
* when making Flow check .android.js files. */
fetch: function(): Promise {
isReduceMotionEnabled: function(): Promise<boolean> {
return new Promise((resolve, reject) => {
RCTAccessibilityInfo.isTouchExplorationEnabled(function(resp) {
resolve(resp);
});
RCTAccessibilityInfo.isReduceMotionEnabled(resolve);
});
},

isScreenReaderEnabled: function(): Promise<boolean> {
return new Promise((resolve, reject) => {
RCTAccessibilityInfo.isTouchExplorationEnabled(resolve);
});
},

/**
* Deprecated
*
* Same as `isScreenReaderEnabled`
*/
get fetch() {
return this.isScreenReaderEnabled;
},

addEventListener: function(
eventName: ChangeEventName,
handler: Function,
): void {
const listener = RCTDeviceEventEmitter.addListener(
TOUCH_EXPLORATION_EVENT,
enabled => {
handler(enabled);
},
);
let listener;

if (eventName === 'change' || eventName === 'screenReaderChanged') {
listener = RCTDeviceEventEmitter.addListener(
TOUCH_EXPLORATION_EVENT,
enabled => {
handler(enabled);
},
);
} else if (eventName === 'reduceMotionChanged') {
listener = RCTDeviceEventEmitter.addListener(
REDUCE_MOTION_EVENT,
enabled => {
handler(enabled);
},
);
}

_subscriptions.set(handler, listener);
},

@@ -16,12 +16,15 @@ const RCTDeviceEventEmitter = require('RCTDeviceEventEmitter');

const AccessibilityManager = NativeModules.AccessibilityManager;

const VOICE_OVER_EVENT = 'voiceOverDidChange';
const ANNOUNCEMENT_DID_FINISH_EVENT = 'announcementDidFinish';
const REDUCE_MOTION_EVENT = 'reduceMotionDidChange';
const VOICE_OVER_EVENT = 'voiceOverDidChange';

type ChangeEventName = $Enum<{
change: string,
announcementFinished: string,
change: string,
reduceMotionChanged: string,
screenReaderChanged: string,
}>;

const _subscriptions = new Map();
@@ -37,23 +40,50 @@ const _subscriptions = new Map();
*/
const AccessibilityInfo = {
/**
* Query whether a screen reader is currently enabled.
* Query whether a reduce motion is currently enabled.
*
* Returns a promise which resolves to a boolean.
* The result is `true` when a screen reader is enabledand `false` otherwise.
*
* See http://facebook.github.io/react-native/docs/accessibilityinfo.html#fetch
* See http://facebook.github.io/react-native/docs/accessibilityinfo.html#isReduceMotionEnabled
*/
fetch: function(): Promise {
isReduceMotionEnabled: function(): Promise {
return new Promise((resolve, reject) => {
AccessibilityManager.getReduceMotionState(resolve, reject);
});
},

/**
* 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.
*
* See http://facebook.github.io/react-native/docs/accessibilityinfo.html#isScreenReaderEnabled
*/
isScreenReaderEnabled: function(): Promise {
return new Promise((resolve, reject) => {
AccessibilityManager.getCurrentVoiceOverState(resolve, reject);
});
},

/**
* Deprecated
*
* Same as `isScreenReaderEnabled`
*/
get fetch() {
return this.isScreenReaderEnabled;
},

/**
* Add an event handler. Supported events:
*
* - `change`: Fires when the state of the screen reader changes. The argument
* - `reduceMotionChanged`: Fires when the state of the reduce motion toggle changes.
* The argument to the event handler is a boolean. The boolean is `true` when a reduce
* motion is enabled (or when "Transition Animation Scale" in "Developer options" is
* "Animation off") and `false` otherwise.
* - `screenReaderChanged`: 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.
* - `announcementFinished`: iOS-only event. Fires when the screen reader has
@@ -71,8 +101,13 @@ const AccessibilityInfo = {
): Object {
let listener;

if (eventName === 'change') {
if (eventName === 'change' || eventName === 'screenReaderChanged') {
listener = RCTDeviceEventEmitter.addListener(VOICE_OVER_EVENT, handler);
} else if (eventName === 'reduceMotionChanged') {
listener = RCTDeviceEventEmitter.addListener(
REDUCE_MOTION_EVENT,
handler,
);
} else if (eventName === 'announcementFinished') {
listener = RCTDeviceEventEmitter.addListener(
ANNOUNCEMENT_DID_FINISH_EVENT,
@@ -19,6 +19,7 @@ extern NSString *const RCTAccessibilityManagerDidUpdateMultiplierNotification; /
/// map from UIKit categories to multipliers
@property (nonatomic, copy) NSDictionary<NSString *, NSNumber *> *multipliers;

@property (nonatomic, assign) BOOL isReduceMotionEnabled;
@property (nonatomic, assign) BOOL isVoiceOverEnabled;

@end
@@ -76,7 +76,13 @@ - (instancetype)init
name:UIAccessibilityAnnouncementDidFinishNotification
object:nil];

[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(reduceMotionStatusDidChange:)
name:UIAccessibilityReduceMotionStatusDidChangeNotification
object:nil];

self.contentSizeCategory = RCTSharedApplication().preferredContentSizeCategory;
_isReduceMotionEnabled = UIAccessibilityIsReduceMotionEnabled();
_isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning();
}
return self;
@@ -119,6 +125,19 @@ - (void)accessibilityAnnouncementDidFinish:(__unused NSNotification *)notificati
#pragma clang diagnostic pop
}

- (void)reduceMotionStatusDidChange:(__unused NSNotification *)notification
{
BOOL newReduceMotionEnabled = UIAccessibilityIsReduceMotionEnabled();
if (_isReduceMotionEnabled != newReduceMotionEnabled) {
_isReduceMotionEnabled = newReduceMotionEnabled;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
[_bridge.eventDispatcher sendDeviceEventWithName:@"reduceMotionDidChange"
body:@(_isReduceMotionEnabled)];
#pragma clang diagnostic pop
}
}

- (void)setContentSizeCategory:(NSString *)contentSizeCategory
{
if (_contentSizeCategory != contentSizeCategory) {
@@ -207,6 +226,12 @@ - (void)setMultipliers:(NSDictionary<NSString *, NSNumber *> *)multipliers
callback(@[@(_isVoiceOverEnabled)]);
}

RCT_EXPORT_METHOD(getReduceMotionState:(RCTResponseSenderBlock)callback
error:(__unused RCTResponseSenderBlock)error)
{
callback(@[@(_isReduceMotionEnabled)]);
}

@end

@implementation RCTBridge (RCTAccessibilityManager)
@@ -9,7 +9,13 @@

import android.annotation.TargetApi;
import android.content.Context;
import android.content.ContentResolver;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.view.accessibility.AccessibilityManager;

import com.facebook.react.bridge.Callback;
@@ -36,21 +42,42 @@

@Override
public void onTouchExplorationStateChanged(boolean enabled) {
updateAndSendChangeEvent(enabled);
updateAndSendTouchExplorationChangeEvent(enabled);
}
}

// Listener that is notified when the global TRANSITION_ANIMATION_SCALE.
private final ContentObserver animationScaleObserver = new ContentObserver(new Handler(Looper.getMainLooper())) {
@Override
public void onChange(boolean selfChange) {
this.onChange(selfChange, null);
}

@Override
public void onChange(boolean selfChange, Uri uri) {
if (getReactApplicationContext().hasActiveCatalystInstance()) {
AccessibilityInfoModule.this.updateAndSendReduceMotionChangeEvent();
}
}
};

private @Nullable AccessibilityManager mAccessibilityManager;
private @Nullable ReactTouchExplorationStateChangeListener mTouchExplorationStateChangeListener;
private boolean mEnabled = false;
private final ContentResolver mContentResolver;
private boolean mReduceMotionEnabled = false;
private boolean mTouchExplorationEnabled = false;

private static final String EVENT_NAME = "touchExplorationDidChange";
private static final String REDUCE_MOTION_EVENT_NAME = "reduceMotionDidChange";
private static final String TOUCH_EXPLORATION_EVENT_NAME = "touchExplorationDidChange";

public AccessibilityInfoModule(ReactApplicationContext context) {
super(context);
Context appContext = context.getApplicationContext();
mAccessibilityManager = (AccessibilityManager) appContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
mEnabled = mAccessibilityManager.isTouchExplorationEnabled();
mContentResolver = getReactApplicationContext().getContentResolver();
mTouchExplorationEnabled = mAccessibilityManager.isTouchExplorationEnabled();
mReduceMotionEnabled = this.getIsReduceMotionEnabledValue();

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
mTouchExplorationStateChangeListener = new ReactTouchExplorationStateChangeListener();
}
@@ -61,16 +88,41 @@ public String getName() {
return "AccessibilityInfo";
}

private boolean getIsReduceMotionEnabledValue() {
String value = Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1 ? null
: Settings.Global.getString(
mContentResolver,
Settings.Global.TRANSITION_ANIMATION_SCALE
);

return value != null && value.equals("0.0");
}

@ReactMethod
public void isReduceMotionEnabled(Callback successCallback) {
successCallback.invoke(mReduceMotionEnabled);
}

@ReactMethod
public void isTouchExplorationEnabled(Callback successCallback) {
successCallback.invoke(mEnabled);
successCallback.invoke(mTouchExplorationEnabled);
}

private void updateAndSendReduceMotionChangeEvent() {
boolean isReduceMotionEnabled = this.getIsReduceMotionEnabledValue();

if (mReduceMotionEnabled != isReduceMotionEnabled) {
mReduceMotionEnabled = isReduceMotionEnabled;
getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(REDUCE_MOTION_EVENT_NAME, mReduceMotionEnabled);
}
}

private void updateAndSendChangeEvent(boolean enabled) {
if (mEnabled != enabled) {
mEnabled = enabled;
private void updateAndSendTouchExplorationChangeEvent(boolean enabled) {
if (mTouchExplorationEnabled != enabled) {
mTouchExplorationEnabled = enabled;
getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(EVENT_NAME, mEnabled);
.emit(TOUCH_EXPLORATION_EVENT_NAME, mTouchExplorationEnabled);
}
}

@@ -80,7 +132,14 @@ public void onHostResume() {
mAccessibilityManager.addTouchExplorationStateChangeListener(
mTouchExplorationStateChangeListener);
}
updateAndSendChangeEvent(mAccessibilityManager.isTouchExplorationEnabled());

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
Uri transitionUri = Settings.Global.getUriFor(Settings.Global.TRANSITION_ANIMATION_SCALE);
mContentResolver.registerContentObserver(transitionUri, false, animationScaleObserver);
}

updateAndSendTouchExplorationChangeEvent(mAccessibilityManager.isTouchExplorationEnabled());
updateAndSendReduceMotionChangeEvent();
}

@Override
@@ -89,12 +148,17 @@ public void onHostPause() {
mAccessibilityManager.removeTouchExplorationStateChangeListener(
mTouchExplorationStateChangeListener);
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
mContentResolver.unregisterContentObserver(animationScaleObserver);
}
}

@Override
public void initialize() {
getReactApplicationContext().addLifecycleEventListener(this);
updateAndSendChangeEvent(mAccessibilityManager.isTouchExplorationEnabled());
updateAndSendTouchExplorationChangeEvent(mAccessibilityManager.isTouchExplorationEnabled());
updateAndSendReduceMotionChangeEvent();
}

@Override

0 comments on commit 0090ab3

Please sign in to comment.
You can’t perform that action at this time.