diff --git a/packages/jest-preset/jest/mocks/AccessibilityInfo.js b/packages/jest-preset/jest/mocks/AccessibilityInfo.js index 4dc215bcabd7..fbd7740e15f3 100644 --- a/packages/jest-preset/jest/mocks/AccessibilityInfo.js +++ b/packages/jest-preset/jest/mocks/AccessibilityInfo.js @@ -20,6 +20,10 @@ const AccessibilityInfo = { isAccessibilityServiceEnabled: jest.fn(() => Promise.resolve(false), ) as JestMockFn<$FlowFixMe, $FlowFixMe>, + isSwitchControlEnabled: jest.fn(() => Promise.resolve(false)) as JestMockFn< + $FlowFixMe, + $FlowFixMe, + >, isBoldTextEnabled: jest.fn(() => Promise.resolve(false)) as JestMockFn< $FlowFixMe, $FlowFixMe, diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index b922c77f0626..14928d7ae7f8 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -30,6 +30,7 @@ type AccessibilityEventDefinitionsIOS = { boldTextChanged: [boolean], darkerSystemColorsChanged: [boolean], reduceTransparencyChanged: [boolean], + switchControlChanged: [boolean], }; type AccessibilityEventDefinitions = { @@ -67,6 +68,7 @@ const EventNames: Map = ['reduceTransparencyChanged', 'reduceTransparencyChanged'], ['screenReaderChanged', 'screenReaderChanged'], ['darkerSystemColorsChanged', 'darkerSystemColorsChanged'], + ['switchControlChanged', 'switchControlChanged'], ]); /** @@ -287,6 +289,37 @@ const AccessibilityInfo = { } }, + /** + * Query whether Switch Control is currently running. + * + * Returns a promise which resolves to a boolean. + * The result is `true` when Switch Control running is enabled and `false` otherwise. + * + * @platform ios + */ + isSwitchControlEnabled(): Promise { + if (Platform.OS === 'android') { + return Promise.resolve(false); + } else { + return new Promise((resolve, reject) => { + if ( + NativeAccessibilityManagerIOS?.getCurrentSwitchControlState != null + ) { + NativeAccessibilityManagerIOS.getCurrentSwitchControlState( + resolve, + reject, + ); + } else { + reject( + new Error( + 'NativeAccessibilityManagerIOS.getCurrentSwitchControlState is not available', + ), + ); + } + }); + } + }, + /** * Query whether reduced transparency is currently enabled. * @@ -407,6 +440,9 @@ const AccessibilityInfo = { * toggle changes. The argument to the event handler is a boolean. The boolean is `true` when * dark system colors is enabled and `false` otherwise. * + * - `switchControlChanged`: iOS-only event. Fires when Switch Control starts or stops running. + * The argument to the event handler is a boolean. + * * These events are only supported on Android: * * - `highTextContrastChanged`: Android-only event. Fires when the state of the high text contrast diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-switchControlChanged-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-switchControlChanged-test.js new file mode 100644 index 000000000000..a5c952a6bcf0 --- /dev/null +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-switchControlChanged-test.js @@ -0,0 +1,72 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import typeof RCTDeviceEventEmitterType from '../../../EventEmitter/RCTDeviceEventEmitter'; +import typeof AccessibilityInfoType from '../AccessibilityInfo'; + +const invariant = require('invariant'); + +jest.unmock('../AccessibilityInfo'); + +type LoadedModules = { + AccessibilityInfo: AccessibilityInfoType, + RCTDeviceEventEmitter: RCTDeviceEventEmitterType, +}; + +// `AccessibilityInfo` picks its native event names when it is first loaded, +// so each platform needs a fresh module registry. +function loadForPlatform(os: 'android' | 'ios'): LoadedModules { + let modules: ?LoadedModules = null; + jest.isolateModules(() => { + const Platform = require('../../../Utilities/Platform').default; + /* $FlowFixMe[incompatible-type] */ + Platform.OS = os; + modules = { + AccessibilityInfo: require('../AccessibilityInfo').default, + RCTDeviceEventEmitter: + require('../../../EventEmitter/RCTDeviceEventEmitter').default, + }; + }); + invariant(modules != null, 'Expected modules to load'); + return modules; +} + +describe('AccessibilityInfo switchControlChanged', () => { + it('delivers switchControlChanged on iOS', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'switchControlChanged', + handler, + ); + RCTDeviceEventEmitter.emit('switchControlChanged', true); + subscription.remove(); + RCTDeviceEventEmitter.emit('switchControlChanged', false); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(true); + }); + + it('does not subscribe on Android', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = + loadForPlatform('android'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'switchControlChanged', + handler, + ); + RCTDeviceEventEmitter.emit('switchControlChanged', true); + subscription.remove(); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js index 9e3d63ca330d..5d5e3c706fa0 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -16,6 +16,9 @@ const mockGetCurrentPrefersCrossFadeTransitionsState = jest.fn( const mockGetCurrentDarkerSystemColorsState = jest.fn((onSuccess, onError) => onSuccess(true), ); +const mockGetCurrentSwitchControlState = jest.fn((onSuccess, onError) => + onSuccess(true), +); const mockGetCurrentBoldTextState = jest.fn((onSuccess, onError) => onSuccess(true), ); @@ -35,6 +38,13 @@ const mockGetCurrentVoiceOverState = jest.fn((onSuccess, onError) => onSuccess(true), ); let mockNativeAccessibilityManagerIOS: { + getCurrentSwitchControlState: JestMockFn< + [ + onSuccess: (isSwitchControlEnabled: boolean) => void, + onError: (error: Error) => void, + ], + void, + > | null, getCurrentPrefersCrossFadeTransitionsState: JestMockFn< [ onSuccess: (prefersCrossFadeTransitions: boolean) => void, @@ -92,6 +102,7 @@ let mockNativeAccessibilityManagerIOS: { void, > | null, } = { + getCurrentSwitchControlState: mockGetCurrentSwitchControlState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, getCurrentDarkerSystemColorsState: mockGetCurrentDarkerSystemColorsState, @@ -660,6 +671,49 @@ describe('AccessibilityInfo', () => { }); }); + describe('isSwitchControlEnabled', () => { + describe('Android', () => { + it('should return immediately', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const result = await AccessibilityInfo.isSwitchControlEnabled(); + + expect(result).toBe(false); + }); + }); + + describe('iOS', () => { + it('should call getCurrentSwitchControlState if available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + const result = await AccessibilityInfo.isSwitchControlEnabled(); + + expect(mockGetCurrentSwitchControlState).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should throw error if getCurrentSwitchControlState is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + mockNativeAccessibilityManagerIOS.getCurrentSwitchControlState = null; + + const result: unknown = + await AccessibilityInfo.isSwitchControlEnabled().catch(e => e); + + invariant( + result instanceof Error, + 'Expected isSwitchControlEnabled to reject', + ); + expect(result.message).toEqual( + 'NativeAccessibilityManagerIOS.getCurrentSwitchControlState is not available', + ); + }); + }); + }); + describe('isDarkerSystemColorsEnabled', () => { describe('Android', () => { it('should return immediately', async () => { @@ -791,6 +845,7 @@ describe('AccessibilityInfo', () => { afterEach(() => { mockNativeAccessibilityManagerIOS = { + getCurrentSwitchControlState: mockGetCurrentSwitchControlState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, getCurrentDarkerSystemColorsState: mockGetCurrentDarkerSystemColorsState, diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager.h b/packages/react-native/React/CoreModules/RCTAccessibilityManager.h index e8ea831b931b..d7b871814872 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager.h +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager.h @@ -27,6 +27,7 @@ extern NSString *const RCTAccessibilityManagerDidUpdateMultiplierNotification; / @property (nonatomic, assign) BOOL isDarkerSystemColorsEnabled; @property (nonatomic, assign) BOOL prefersCrossFadeTransitions; @property (nonatomic, assign) BOOL isReduceTransparencyEnabled; +@property (nonatomic, assign) BOOL isSwitchControlEnabled; @property (nonatomic, assign) BOOL isVoiceOverEnabled; @end diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm index 7ff290fdce80..b9ce1839ffc9 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm @@ -91,6 +91,11 @@ - (instancetype)init name:UIAccessibilityReduceTransparencyStatusDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(switchControlStatusDidChange:) + name:UIAccessibilitySwitchControlStatusDidChangeNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(voiceVoiceOverStatusDidChange:) name:UIAccessibilityVoiceOverStatusDidChangeNotification @@ -103,6 +108,7 @@ - (instancetype)init _isReduceMotionEnabled = UIAccessibilityIsReduceMotionEnabled(); _isDarkerSystemColorsEnabled = UIAccessibilityDarkerSystemColorsEnabled(); _isReduceTransparencyEnabled = UIAccessibilityIsReduceTransparencyEnabled(); + _isSwitchControlEnabled = UIAccessibilityIsSwitchControlRunning(); _isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); } return self; @@ -207,6 +213,19 @@ - (void)reduceTransparencyStatusDidChange:(__unused NSNotification *)notificatio } } +- (void)switchControlStatusDidChange:(__unused NSNotification *)notification +{ + BOOL newValue = UIAccessibilityIsSwitchControlRunning(); + if (_isSwitchControlEnabled != newValue) { + _isSwitchControlEnabled = newValue; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"switchControlChanged" + body:@(_isSwitchControlEnabled)]; +#pragma clang diagnostic pop + } +} + - (void)voiceVoiceOverStatusDidChange:(__unused NSNotification *)notification { BOOL isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); @@ -411,6 +430,11 @@ - (void)getCurrentReduceTransparencyState:(RCTResponseSenderBlock)onSuccess onSuccess(@[ @(_isReduceTransparencyEnabled) ]); } +- (void)getCurrentSwitchControlState:(RCTResponseSenderBlock)onSuccess onError:(__unused RCTResponseSenderBlock)onError +{ + onSuccess(@[ @(_isSwitchControlEnabled) ]); +} + - (void)getCurrentVoiceOverState:(RCTResponseSenderBlock)onSuccess onError:(__unused RCTResponseSenderBlock)onError { onSuccess(@[ @(_isVoiceOverEnabled) ]); diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 74c407136ff1..fe0879b8b1af 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<24620c82ef802825ff274d4a28fe39c8>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -101,6 +101,7 @@ declare const AccessibilityInfo_default: { isReduceMotionEnabled(): Promise isReduceTransparencyEnabled(): Promise isScreenReaderEnabled(): Promise + isSwitchControlEnabled(): Promise prefersCrossFadeTransitions(): Promise sendAccessibilityEvent( handle: HostInstance, @@ -974,6 +975,7 @@ declare type AccessibilityEventDefinitionsIOS = { boldTextChanged: [boolean] darkerSystemColorsChanged: [boolean] reduceTransparencyChanged: [boolean] + switchControlChanged: [boolean] } declare type AccessibilityEventTypes = "click" | "focus" | "viewHoverEnter" | "windowStateChange" @@ -5770,7 +5772,7 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // d92c5eae + AccessibilityInfo, // 016dac1b AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 54ea8b7694ba..3d5440c1fc2e 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -1464,6 +1464,12 @@ class AccessibilityTest extends React.Component { } } +AccessibilityInfo.isSwitchControlEnabled().then(isEnabled => + console.log(`AccessibilityInfo.isSwitchControlEnabled => ${isEnabled}`), +); +AccessibilityInfo.addEventListener('switchControlChanged', isEnabled => + console.log(`AccessibilityInfo.switchControlChanged => ${isEnabled}`), +); AccessibilityInfo.isBoldTextEnabled().then(isEnabled => console.log(`AccessibilityInfo.isBoldTextEnabled => ${isEnabled}`), ); diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js index f30e52bc0b2f..7985c1a8c0de 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js @@ -38,6 +38,10 @@ export interface Spec extends TurboModule { onSuccess: (prefersCrossFadeTransitions: boolean) => void, onError: (error: UnsafeObject) => void, ) => void; + readonly getCurrentSwitchControlState?: ( + onSuccess: (isSwitchControlEnabled: boolean) => void, + onError: (error: UnsafeObject) => void, + ) => void; readonly getCurrentReduceTransparencyState: ( onSuccess: (isReduceTransparencyEnabled: boolean) => void, onError: (error: UnsafeObject) => void, diff --git a/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts index 80e5fe6a3295..3a971d5b3c2b 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts @@ -26,7 +26,9 @@ type AccessibilityChangeEventName = | 'reduceMotionChanged' /** iOS only */ | 'reduceTransparencyChanged' - | 'screenReaderChanged'; + | 'screenReaderChanged' + /** iOS only */ + | 'switchControlChanged'; type AccessibilityChangeEvent = boolean; @@ -86,6 +88,13 @@ export interface AccessibilityInfoStatic { */ isHighTextContrastEnabled: () => Promise; + /** + * Query whether Switch Control is currently running. + * + * @platform ios + */ + isSwitchControlEnabled: () => Promise; + /** * Query whether darker system colors is currently enabled. * diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 2ac5879c9458..203876ead242 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -1594,6 +1594,11 @@ class DisplayOptionsStatusExample extends React.Component<{}> { optionChecker={AccessibilityInfo.isInvertColorsEnabled} notification={'invertColorsChanged'} /> +