feat: support queuing accessibility announcements on ios (#32637)

Summary:
The current implementation of `AccessibilityInfo.announceForAccessibility` will immediately interrupt any existing in progress speech with the announcement. Sometimes this is desirable behaviour, but often you will want to wait until existing speech is finished before reading the new announcement. This change gives us that option.

My personal use case for this feature is a custom text input. When typing on iOS with voiceover enabled, each character is read out after being selected. I wanted to add some additional information after each character to help with the context of what has changed in the input, but I didn't want to override the reading of the character itself.

This feature is supported natively on iOS by constructing an `NSAttributedString` with the property [`accessibilitySpeechQueueAnnouncement`](https://developer.apple.com/documentation/foundation/nsattributedstring/key/2865770-accessibilityspeechqueueannounce), so this change just adds an extra parameter to `AccessibilityInfo.announceForAccessibility` which controls the value of that property on the native side. Adding this as an extra optional parameter with false as the default ensures that existing uses of the function won't be affected.

Unfortunately, this feature doesn't appear to be supported on Android, so the new second property will be iOS only.

## Changelog

[iOS] [Added] - add new argument to announceForAccessibility to allow queueing on iOS

Pull Request resolved: https://github.com/facebook/react-native/pull/32637

Test Plan:
I've updated the `announceForAccessibility` section in RNTester with multiple buttons to demonstrate the difference between `queue: false` (default) and `queue: true` and show they work as intended.

Here's the expectation for each button:

- "Announce for Accessibility Immediately": on press, should start reading the button label, then be interrupted by the announcement
- "Announce for Accessibility Queued": on press, should read the button label then read the announcement afterwards
- "Announce for Accessibility Queue Multiple": on press, should read the button label, then read three announcements sequentially, no interruptions

You can see the realisation of those expectations in the following video recorded on an iPhone 12 running iOS 15.0.2:

https://user-images.githubusercontent.com/14826539/142770536-d57bfd69-eba5-444d-9c89-4bf4851ea062.mov

I've also tested the same way on an iPhone 8 running iOS 13.4 and it works exactly the same.

Reviewed By: yungsters

Differential Revision: D32637989

Pulled By: philIip

fbshipit-source-id: 3e90add523f11eb0eb34ea623211249263f257e2
This commit is contained in:
Peter Carpenter
2021-12-02 00:32:49 -08:00
committed by Facebook GitHub Bot
parent 2bb91ae0e9
commit 4d1357918a
5 changed files with 110 additions and 5 deletions
@@ -341,6 +341,30 @@ const AccessibilityInfo = {
}
},
/**
* Post a string to be announced by the screen reader.
* - `announcement`: The string announced by the screen reader.
* - `options`: An object that configures the reading options.
* - `queue`: The announcement will be queued behind existing announcements. iOS only.
*/
announceForAccessibilityWithOptions(
announcement: string,
options: {queue?: boolean},
): void {
if (Platform.OS === 'android') {
NativeAccessibilityInfoAndroid?.announceForAccessibility(announcement);
} else {
if (NativeAccessibilityManagerIOS?.announceForAccessibilityWithOptions) {
NativeAccessibilityManagerIOS?.announceForAccessibilityWithOptions(
announcement,
options,
);
} else {
NativeAccessibilityManagerIOS?.announceForAccessibility(announcement);
}
}
},
/**
* @deprecated Use `remove` on the EventSubscription from `addEventListener`.
*/
@@ -52,6 +52,10 @@ export interface Spec extends TurboModule {
|}) => void;
+setAccessibilityFocus: (reactTag: number) => void;
+announceForAccessibility: (announcement: string) => void;
+announceForAccessibilityWithOptions?: (
announcement: string,
options: {queue?: boolean},
) => void;
}
export default (TurboModuleRegistry.get<Spec>('AccessibilityManager'): ?Spec);
@@ -301,6 +301,28 @@ RCT_EXPORT_METHOD(announceForAccessibility : (NSString *)announcement)
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, announcement);
}
RCT_EXPORT_METHOD(announceForAccessibilityWithOptions
: (NSString *)announcement options
: (JS::NativeAccessibilityManager::SpecAnnounceForAccessibilityWithOptionsOptions &)options)
{
if (@available(iOS 11.0, *)) {
NSMutableDictionary<NSString *, NSNumber *> *attrsDictionary = [NSMutableDictionary new];
if (options.queue()) {
attrsDictionary[UIAccessibilitySpeechAttributeQueueAnnouncement] = @(*(options.queue()) ? YES : NO);
}
if (attrsDictionary.count > 0) {
NSAttributedString *announcementWithAttrs = [[NSAttributedString alloc] initWithString:announcement
attributes:attrsDictionary];
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, announcementWithAttrs);
} else {
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, announcement);
}
} else {
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, announcement);
}
}
RCT_EXPORT_METHOD(getMultiplier : (RCTResponseSenderBlock)callback)
{
if (callback) {
+3 -3
View File
@@ -881,7 +881,7 @@ SPEC CHECKSUMS:
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662
FBLazyVector: b81a2b70c72d8b0aefb652cea22c11e9ffd02949
FBReactNativeSpec: 755b7fee1b08aefd74fb2fa9f7312b253719d536
FBReactNativeSpec: 37e065c0cfc5da966014bf62b50edb066d8206cd
Flipper: 30e8eeeed6abdc98edaf32af0cda2f198be4b733
Flipper-Boost-iOSX: fd1e2b8cbef7e662a122412d7ac5f5bea715403c
Flipper-DoubleConversion: 57ffbe81ef95306cc9e69c4aa3aeeeeb58a6a28c
@@ -923,10 +923,10 @@ SPEC CHECKSUMS:
React-RCTTest: 12bbd7fc2e72bd9920dc7286c5b8ef96639582b6
React-RCTText: e9146b2c0550a83d1335bfe2553760070a2d75c7
React-RCTVibration: 50be9c390f2da76045ef0dfdefa18b9cf9f35cfa
React-rncore: d09af3a25cbff0b484776785676c28f3729e07f5
React-rncore: c57d93f56e2d385bdbda34eae2d20d4d3c0c8b4a
React-runtimeexecutor: 4b0c6eb341c7d3ceb5e2385cb0fdb9bf701024f3
ReactCommon: 7a2714d1128f965392b6f99a8b390e3aa38c9569
ScreenshotManager: e8a3fc9b2e24b81127b36cb4ebe0eed65090c949
ScreenshotManager: 9f69049876d8aafafa13a1a635baa8f7e168eee4
Yoga: c0d06f5380d34e939f55420669a60fe08b79bd75
YogaKit: f782866e155069a2cca2517aafea43200b01fd5a
@@ -864,10 +864,65 @@ class FakeSliderExample extends React.Component<{}, FakeSliderExampleState> {
class AnnounceForAccessibility extends React.Component<{}> {
_handleOnPress = () =>
AccessibilityInfo.announceForAccessibility('Announcement Test');
setTimeout(
() => AccessibilityInfo.announceForAccessibility('Announcement Test'),
1000,
);
_handleOnPressQueued = () =>
setTimeout(
() =>
AccessibilityInfo.announceForAccessibilityWithOptions(
'Queued Announcement Test',
{queue: true},
),
1000,
);
_handleOnPressQueueMultiple = () => {
setTimeout(
() =>
AccessibilityInfo.announceForAccessibilityWithOptions(
'First Queued Announcement Test',
{queue: true},
),
1000,
);
setTimeout(
() =>
AccessibilityInfo.announceForAccessibilityWithOptions(
'Second Queued Announcement Test',
{queue: true},
),
1100,
);
setTimeout(
() =>
AccessibilityInfo.announceForAccessibilityWithOptions(
'Third Queued Announcement Test',
{queue: true},
),
1200,
);
};
render(): React.Node {
return (
return Platform.OS === 'ios' ? (
<View>
<Button
onPress={this._handleOnPress}
title="Announce for Accessibility Immediately"
/>
<Button
onPress={this._handleOnPressQueued}
title="Announce for Accessibility Queued"
/>
<Button
onPress={this._handleOnPressQueueMultiple}
title="Announce for Accessibility Queue Multiple"
/>
</View>
) : (
<View>
<Button
onPress={this._handleOnPress}