From 4d1357918a4dcb331ccea2140699f487ca45ea30 Mon Sep 17 00:00:00 2001 From: Peter Carpenter Date: Thu, 2 Dec 2021 00:32:49 -0800 Subject: [PATCH] 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 --- .../AccessibilityInfo/AccessibilityInfo.js | 24 ++++++++ .../NativeAccessibilityManager.js | 4 ++ React/CoreModules/RCTAccessibilityManager.mm | 22 +++++++ packages/rn-tester/Podfile.lock | 6 +- .../Accessibility/AccessibilityExample.js | 59 ++++++++++++++++++- 5 files changed, 110 insertions(+), 5 deletions(-) diff --git a/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index d7c0859c3f4..0eba2c14304 100644 --- a/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -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`. */ diff --git a/Libraries/Components/AccessibilityInfo/NativeAccessibilityManager.js b/Libraries/Components/AccessibilityInfo/NativeAccessibilityManager.js index 9ee2403e372..78dc83170ad 100644 --- a/Libraries/Components/AccessibilityInfo/NativeAccessibilityManager.js +++ b/Libraries/Components/AccessibilityInfo/NativeAccessibilityManager.js @@ -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('AccessibilityManager'): ?Spec); diff --git a/React/CoreModules/RCTAccessibilityManager.mm b/React/CoreModules/RCTAccessibilityManager.mm index 3d0c7765d05..29e2fcbedda 100644 --- a/React/CoreModules/RCTAccessibilityManager.mm +++ b/React/CoreModules/RCTAccessibilityManager.mm @@ -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 *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) { diff --git a/packages/rn-tester/Podfile.lock b/packages/rn-tester/Podfile.lock index 518392bdf56..756549efd02 100644 --- a/packages/rn-tester/Podfile.lock +++ b/packages/rn-tester/Podfile.lock @@ -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 diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index b794565169c..eb25ee596d3 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -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' ? ( + +