Skip to content

Commit 62a1fc1

Browse files
camilopiresmeta-codesync[bot]
authored andcommitted
Add AccessibilityInfo.isShowBordersEnabled and showBordersChanged on iOS (#59177)
Summary: iOS's Show Borders setting (called Button Shapes before iOS 26.1) outlines tappable controls. UIKit and SwiftUI buttons follow it automatically, but React Native apps can't detect it, so custom `Pressable` buttons can't match. It can also change layout (expo/expo#48058). This adds `AccessibilityInfo.isShowBordersEnabled()` and a `showBordersChanged` event, following the existing `isDarkerSystemColorsEnabled` pattern. - iOS 26.1 deprecated `UIAccessibilityButtonShapesEnabled()` in favour of `AXShowBordersEnabled()` in the Accessibility framework. - The module uses the new API on 26.1 and later, guarded by `__IPHONE_OS_VERSION_MAX_ALLOWED` for older SDKs and by `available` at runtime, and the UIKit API below that. - React-CoreModules now links the Accessibility framework, in both the podspec and `Package.swift`. The framework exists from iOS 14, below the 15.1 minimum, and the new symbol is weak-linked through `available`. On Android, the method resolves to `false`. Discussion: react-native-community/discussions-and-proposals#1026 ## Changelog: [IOS] [ADDED] - `AccessibilityInfo.isShowBordersEnabled()` and `showBordersChanged` (Show Borders, formerly Button Shapes) Pull Request resolved: #59177 Test Plan: - `yarn jest packages/react-native/Libraries/Components/AccessibilityInfo`: all tests pass - `yarn flow-check`: 0 errors - `yarn lint`: passes - `yarn build-types --validate`: API snapshot up to date - `yarn test-generated-typescript` and `yarn test-typescript-legacy`: pass - `yarn format-check-cpp`: passes - RNTester builds for the iOS simulator: `xcodebuild -workspace RNTesterPods.xcworkspace -scheme RNTester -sdk iphonesimulator`, using Xcode 27.1 On an iPhone 18 Pro simulator running iOS 27.0, which uses the `AXShowBordersEnabled` path: I flipped the setting in the simulator's Settings app with a throwaway XCUITest, not part of this PR, and read RNTester's Accessibility → "Check if the display options are enabled" row. RNTester stayed running the whole time, so the row could only update through the change event. - Before: "Show Borders is disabled." - Show Borders turned on: "Show Borders is enabled." - Turned off: "Show Borders is disabled." Not run: iOS below 26.1, which uses the UIKit fallback. No older simulator runtime was available. Reviewed By: cipolleschi Differential Revision: D126037985 Pulled By: javache fbshipit-source-id: 4fb84b25e5cfbc9c4793d26d9946957d42b38466
1 parent 62fd30f commit 62a1fc1

17 files changed

Lines changed: 261 additions & 4 deletions

File tree

‎packages/jest-preset/jest/mocks/AccessibilityInfo.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const AccessibilityInfo = {
2020
isAccessibilityServiceEnabled: jest.fn(() =>
2121
Promise.resolve(false),
2222
) as JestMockFn<$FlowFixMe, $FlowFixMe>,
23+
isShowBordersEnabled: jest.fn(() => Promise.resolve(false)) as JestMockFn<
24+
$FlowFixMe,
25+
$FlowFixMe,
26+
>,
2327
isBoldTextEnabled: jest.fn(() => Promise.resolve(false)) as JestMockFn<
2428
$FlowFixMe,
2529
$FlowFixMe,

‎packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type AccessibilityEventDefinitionsIOS = {
3030
boldTextChanged: [boolean],
3131
darkerSystemColorsChanged: [boolean],
3232
reduceTransparencyChanged: [boolean],
33+
showBordersChanged: [boolean],
3334
};
3435

3536
type AccessibilityEventDefinitions = {
@@ -67,6 +68,7 @@ const EventNames: Map<keyof AccessibilityEventDefinitions, string> =
6768
['reduceTransparencyChanged', 'reduceTransparencyChanged'],
6869
['screenReaderChanged', 'screenReaderChanged'],
6970
['darkerSystemColorsChanged', 'darkerSystemColorsChanged'],
71+
['showBordersChanged', 'showBordersChanged'],
7072
]);
7173

7274
/**
@@ -287,6 +289,36 @@ const AccessibilityInfo = {
287289
}
288290
},
289291

292+
/**
293+
* Query whether Show Borders (called Button Shapes before iOS 26.1) is
294+
* currently enabled.
295+
*
296+
* Returns a promise which resolves to a boolean.
297+
* The result is `true` when show borders is enabled and `false` otherwise.
298+
*
299+
* @platform ios
300+
*/
301+
isShowBordersEnabled(): Promise<boolean> {
302+
if (Platform.OS === 'android') {
303+
return Promise.resolve(false);
304+
} else {
305+
return new Promise((resolve, reject) => {
306+
if (NativeAccessibilityManagerIOS?.getCurrentShowBordersState != null) {
307+
NativeAccessibilityManagerIOS.getCurrentShowBordersState(
308+
resolve,
309+
reject,
310+
);
311+
} else {
312+
reject(
313+
new Error(
314+
'NativeAccessibilityManagerIOS.getCurrentShowBordersState is not available',
315+
),
316+
);
317+
}
318+
});
319+
}
320+
},
321+
290322
/**
291323
* Query whether reduced transparency is currently enabled.
292324
*
@@ -407,6 +439,10 @@ const AccessibilityInfo = {
407439
* toggle changes. The argument to the event handler is a boolean. The boolean is `true` when
408440
* dark system colors is enabled and `false` otherwise.
409441
*
442+
* - `showBordersChanged`: iOS-only event. Fires when the state of the show borders toggle changes
443+
* (called Button Shapes before iOS 26.1).
444+
* The argument to the event handler is a boolean.
445+
*
410446
* These events are only supported on Android:
411447
*
412448
* - `highTextContrastChanged`: Android-only event. Fires when the state of the high text contrast
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import typeof RCTDeviceEventEmitterType from '../../../EventEmitter/RCTDeviceEventEmitter';
12+
import typeof AccessibilityInfoType from '../AccessibilityInfo';
13+
14+
const invariant = require('invariant');
15+
16+
jest.unmock('../AccessibilityInfo');
17+
18+
type LoadedModules = {
19+
AccessibilityInfo: AccessibilityInfoType,
20+
RCTDeviceEventEmitter: RCTDeviceEventEmitterType,
21+
};
22+
23+
// `AccessibilityInfo` picks its native event names when it is first loaded,
24+
// so each platform needs a fresh module registry.
25+
function loadForPlatform(os: 'android' | 'ios'): LoadedModules {
26+
let modules: ?LoadedModules = null;
27+
jest.isolateModules(() => {
28+
const Platform = require('../../../Utilities/Platform').default;
29+
/* $FlowFixMe[incompatible-type] */
30+
Platform.OS = os;
31+
modules = {
32+
AccessibilityInfo: require('../AccessibilityInfo').default,
33+
RCTDeviceEventEmitter:
34+
require('../../../EventEmitter/RCTDeviceEventEmitter').default,
35+
};
36+
});
37+
invariant(modules != null, 'Expected modules to load');
38+
return modules;
39+
}
40+
41+
describe('AccessibilityInfo showBordersChanged', () => {
42+
it('delivers showBordersChanged on iOS', () => {
43+
const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios');
44+
const handler = jest.fn();
45+
46+
const subscription = AccessibilityInfo.addEventListener(
47+
'showBordersChanged',
48+
handler,
49+
);
50+
RCTDeviceEventEmitter.emit('showBordersChanged', true);
51+
subscription.remove();
52+
RCTDeviceEventEmitter.emit('showBordersChanged', false);
53+
54+
expect(handler).toHaveBeenCalledTimes(1);
55+
expect(handler).toHaveBeenCalledWith(true);
56+
});
57+
58+
it('does not subscribe on Android', () => {
59+
const {AccessibilityInfo, RCTDeviceEventEmitter} =
60+
loadForPlatform('android');
61+
const handler = jest.fn();
62+
63+
const subscription = AccessibilityInfo.addEventListener(
64+
'showBordersChanged',
65+
handler,
66+
);
67+
RCTDeviceEventEmitter.emit('showBordersChanged', true);
68+
subscription.remove();
69+
70+
expect(handler).not.toHaveBeenCalled();
71+
});
72+
});

‎packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const mockGetCurrentPrefersCrossFadeTransitionsState = jest.fn(
1616
const mockGetCurrentDarkerSystemColorsState = jest.fn((onSuccess, onError) =>
1717
onSuccess(true),
1818
);
19+
const mockGetCurrentShowBordersState = jest.fn((onSuccess, onError) =>
20+
onSuccess(true),
21+
);
1922
const mockGetCurrentBoldTextState = jest.fn((onSuccess, onError) =>
2023
onSuccess(true),
2124
);
@@ -35,6 +38,13 @@ const mockGetCurrentVoiceOverState = jest.fn((onSuccess, onError) =>
3538
onSuccess(true),
3639
);
3740
let mockNativeAccessibilityManagerIOS: {
41+
getCurrentShowBordersState: JestMockFn<
42+
[
43+
onSuccess: (isShowBordersEnabled: boolean) => void,
44+
onError: (error: Error) => void,
45+
],
46+
void,
47+
> | null,
3848
getCurrentPrefersCrossFadeTransitionsState: JestMockFn<
3949
[
4050
onSuccess: (prefersCrossFadeTransitions: boolean) => void,
@@ -92,6 +102,7 @@ let mockNativeAccessibilityManagerIOS: {
92102
void,
93103
> | null,
94104
} = {
105+
getCurrentShowBordersState: mockGetCurrentShowBordersState,
95106
getCurrentPrefersCrossFadeTransitionsState:
96107
mockGetCurrentPrefersCrossFadeTransitionsState,
97108
getCurrentDarkerSystemColorsState: mockGetCurrentDarkerSystemColorsState,
@@ -660,6 +671,49 @@ describe('AccessibilityInfo', () => {
660671
});
661672
});
662673

674+
describe('isShowBordersEnabled', () => {
675+
describe('Android', () => {
676+
it('should return immediately', async () => {
677+
/* $FlowFixMe[incompatible-type] */
678+
Platform.OS = 'android';
679+
680+
const result = await AccessibilityInfo.isShowBordersEnabled();
681+
682+
expect(result).toBe(false);
683+
});
684+
});
685+
686+
describe('iOS', () => {
687+
it('should call getCurrentShowBordersState if available', async () => {
688+
/* $FlowFixMe[incompatible-type] */
689+
Platform.OS = 'ios';
690+
691+
const result = await AccessibilityInfo.isShowBordersEnabled();
692+
693+
expect(mockGetCurrentShowBordersState).toHaveBeenCalled();
694+
expect(result).toBe(true);
695+
});
696+
697+
it('should throw error if getCurrentShowBordersState is not available', async () => {
698+
/* $FlowFixMe[incompatible-type] */
699+
Platform.OS = 'ios';
700+
701+
mockNativeAccessibilityManagerIOS.getCurrentShowBordersState = null;
702+
703+
const result: unknown =
704+
await AccessibilityInfo.isShowBordersEnabled().catch(e => e);
705+
706+
invariant(
707+
result instanceof Error,
708+
'Expected isShowBordersEnabled to reject',
709+
);
710+
expect(result.message).toEqual(
711+
'NativeAccessibilityManagerIOS.getCurrentShowBordersState is not available',
712+
);
713+
});
714+
});
715+
});
716+
663717
describe('isDarkerSystemColorsEnabled', () => {
664718
describe('Android', () => {
665719
it('should return immediately', async () => {
@@ -791,6 +845,7 @@ describe('AccessibilityInfo', () => {
791845

792846
afterEach(() => {
793847
mockNativeAccessibilityManagerIOS = {
848+
getCurrentShowBordersState: mockGetCurrentShowBordersState,
794849
getCurrentPrefersCrossFadeTransitionsState:
795850
mockGetCurrentPrefersCrossFadeTransitionsState,
796851
getCurrentDarkerSystemColorsState: mockGetCurrentDarkerSystemColorsState,

‎packages/react-native/Package.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ let reactCoreRCTWebsocket = RNTarget(
416416
let reactCoreModules = RNTarget(
417417
name: .reactCoreModules,
418418
path: "React/CoreModules",
419+
linkedFrameworks: ["Accessibility"],
419420
excludedPaths: ["PlatformStubs/RCTStatusBarManager.mm"],
420421
dependencies: [.reactNativeDependencies, .jsi, .yoga, .reactTurboModuleCore, .reactFeatureFlags]
421422
)

‎packages/react-native/React/CoreModules/RCTAccessibilityManager.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ extern NSString *const RCTAccessibilityManagerDidUpdateMultiplierNotification; /
2727
@property (nonatomic, assign) BOOL isDarkerSystemColorsEnabled;
2828
@property (nonatomic, assign) BOOL prefersCrossFadeTransitions;
2929
@property (nonatomic, assign) BOOL isReduceTransparencyEnabled;
30+
@property (nonatomic, assign) BOOL isShowBordersEnabled;
3031
@property (nonatomic, assign) BOOL isVoiceOverEnabled;
3132

3233
@end

‎packages/react-native/React/CoreModules/RCTAccessibilityManager.mm‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
#import "CoreModulesPlugins.h"
1919

20+
#import <Accessibility/Accessibility.h>
21+
2022
NSString *const RCTAccessibilityManagerDidUpdateMultiplierNotification =
2123
@"RCTAccessibilityManagerDidUpdateMultiplierNotification";
2224

@@ -27,6 +29,34 @@ @interface RCTAccessibilityManager () <NativeAccessibilityManagerSpec>
2729

2830
@end
2931

32+
// Show Borders was called Button Shapes before iOS 26.1, where UIKit deprecated
33+
// its API in favor of the Accessibility framework.
34+
static BOOL RCTIsShowBordersEnabled()
35+
{
36+
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 260100
37+
if (@available(iOS 26.1, *)) {
38+
return AXShowBordersEnabled();
39+
}
40+
#endif
41+
#pragma clang diagnostic push
42+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
43+
return UIAccessibilityButtonShapesEnabled();
44+
#pragma clang diagnostic pop
45+
}
46+
47+
static NSNotificationName RCTShowBordersStatusDidChangeNotification()
48+
{
49+
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 260100
50+
if (@available(iOS 26.1, *)) {
51+
return AXShowBordersEnabledStatusDidChangeNotification;
52+
}
53+
#endif
54+
#pragma clang diagnostic push
55+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
56+
return UIAccessibilityButtonShapesEnabledStatusDidChangeNotification;
57+
#pragma clang diagnostic pop
58+
}
59+
3060
@implementation RCTAccessibilityManager
3161

3262
@synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED;
@@ -91,6 +121,11 @@ - (instancetype)init
91121
name:UIAccessibilityReduceTransparencyStatusDidChangeNotification
92122
object:nil];
93123

124+
[[NSNotificationCenter defaultCenter] addObserver:self
125+
selector:@selector(showBordersStatusDidChange:)
126+
name:RCTShowBordersStatusDidChangeNotification()
127+
object:nil];
128+
94129
[[NSNotificationCenter defaultCenter] addObserver:self
95130
selector:@selector(voiceVoiceOverStatusDidChange:)
96131
name:UIAccessibilityVoiceOverStatusDidChangeNotification
@@ -103,6 +138,7 @@ - (instancetype)init
103138
_isReduceMotionEnabled = UIAccessibilityIsReduceMotionEnabled();
104139
_isDarkerSystemColorsEnabled = UIAccessibilityDarkerSystemColorsEnabled();
105140
_isReduceTransparencyEnabled = UIAccessibilityIsReduceTransparencyEnabled();
141+
_isShowBordersEnabled = RCTIsShowBordersEnabled();
106142
_isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning();
107143
}
108144
return self;
@@ -207,6 +243,19 @@ - (void)reduceTransparencyStatusDidChange:(__unused NSNotification *)notificatio
207243
}
208244
}
209245

246+
- (void)showBordersStatusDidChange:(__unused NSNotification *)notification
247+
{
248+
BOOL newShowBordersEnabled = RCTIsShowBordersEnabled();
249+
if (_isShowBordersEnabled != newShowBordersEnabled) {
250+
_isShowBordersEnabled = newShowBordersEnabled;
251+
#pragma clang diagnostic push
252+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
253+
[[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"showBordersChanged"
254+
body:@(_isShowBordersEnabled)];
255+
#pragma clang diagnostic pop
256+
}
257+
}
258+
210259
- (void)voiceVoiceOverStatusDidChange:(__unused NSNotification *)notification
211260
{
212261
BOOL isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning();
@@ -411,6 +460,11 @@ - (void)getCurrentReduceTransparencyState:(RCTResponseSenderBlock)onSuccess
411460
onSuccess(@[ @(_isReduceTransparencyEnabled) ]);
412461
}
413462

463+
- (void)getCurrentShowBordersState:(RCTResponseSenderBlock)onSuccess onError:(__unused RCTResponseSenderBlock)onError
464+
{
465+
onSuccess(@[ @(_isShowBordersEnabled) ]);
466+
}
467+
414468
- (void)getCurrentVoiceOverState:(RCTResponseSenderBlock)onSuccess onError:(__unused RCTResponseSenderBlock)onError
415469
{
416470
onSuccess(@[ @(_isVoiceOverEnabled) ]);

‎packages/react-native/React/CoreModules/React-CoreModules.podspec‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Pod::Spec.new do |s|
4646
"CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(),
4747
"HEADER_SEARCH_PATHS" => header_search_paths.join(" ")
4848
}
49-
s.framework = "UIKit"
49+
s.frameworks = ["UIKit", "Accessibility"]
5050
s.dependency "RCTTypeSafety", version
5151
s.dependency "React-Core/CoreModulesHeaders", version
5252
s.dependency "React-RCTImage", version

‎packages/react-native/ReactNativeApi.d.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<b0c8c41e65758fde472ddf1c5b106a27>>
7+
* @generated SignedSource<<09cb4ee90e2e29b0924c0359d0ebef39>>
88
*
99
* This file was generated by scripts/js-api/build-types/index.js.
1010
*/
@@ -101,6 +101,7 @@ declare const AccessibilityInfo_default: {
101101
isReduceMotionEnabled(): Promise<boolean>
102102
isReduceTransparencyEnabled(): Promise<boolean>
103103
isScreenReaderEnabled(): Promise<boolean>
104+
isShowBordersEnabled(): Promise<boolean>
104105
prefersCrossFadeTransitions(): Promise<boolean>
105106
sendAccessibilityEvent(
106107
handle: HostInstance,
@@ -974,6 +975,7 @@ declare type AccessibilityEventDefinitionsIOS = {
974975
boldTextChanged: [boolean]
975976
darkerSystemColorsChanged: [boolean]
976977
reduceTransparencyChanged: [boolean]
978+
showBordersChanged: [boolean]
977979
}
978980
declare type AccessibilityEventTypes =
979981
"click" | "focus" | "viewHoverEnter" | "windowStateChange"
@@ -5770,7 +5772,7 @@ declare type WrapperComponentProvider = (
57705772
export {
57715773
AccessibilityActionEvent, // a276ec6e
57725774
AccessibilityActionInfo, // c94545a7
5773-
AccessibilityInfo, // d92c5eae
5775+
AccessibilityInfo, // 7d8718ed
57745776
AccessibilityProps, // 95dbb1d8
57755777
AccessibilityRole, // f2f2e066
57765778
AccessibilityState, // b0c2b3f7

0 commit comments

Comments
 (0)