From 6ee2755b452a6c50c3b4027d0047f961cf4215aa Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:40:17 +0100 Subject: [PATCH] Add AccessibilityInfo.isSwitchControlEnabled on iOS Reads UIAccessibilityIsSwitchControlRunning() and emits switchControlChanged when it changes. Android resolves to false. Adds Jest tests for the getter and the event wiring, and an RNTester row. Co-Authored-By: Claude Opus 5.5 --- .../jest/mocks/AccessibilityInfo.js | 4 ++ .../AccessibilityInfo/AccessibilityInfo.js | 36 ++++++++++ ...ssibilityInfo-switchControlChanged-test.js | 72 +++++++++++++++++++ .../__tests__/AccessibilityInfo-test.js | 55 ++++++++++++++ .../CoreModules/RCTAccessibilityManager.h | 1 + .../CoreModules/RCTAccessibilityManager.mm | 24 +++++++ packages/react-native/ReactNativeApi.d.ts | 6 +- packages/react-native/__typetests__/index.tsx | 6 ++ .../modules/NativeAccessibilityManager.js | 4 ++ .../AccessibilityInfo/AccessibilityInfo.d.ts | 11 ++- .../Accessibility/AccessibilityExample.js | 5 ++ 11 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-switchControlChanged-test.js 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'} /> +