From 47bdcd5f64c5ff24c55940bfde31e42c830fced2 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 3 Jun 2020 14:17:36 -0700 Subject: [PATCH 1/5] Live region announcements for iOS --- .../ios/framework/Source/SemanticsObject.h | 1 + .../ios/framework/Source/SemanticsObject.mm | 19 +++++++++ .../framework/Source/SemanticsObjectTest.mm | 39 +++++++++++++++++++ .../framework/Source/accessibility_bridge.mm | 22 +++++++++++ 4 files changed, 81 insertions(+) diff --git a/shell/platform/darwin/ios/framework/Source/SemanticsObject.h b/shell/platform/darwin/ios/framework/Source/SemanticsObject.h index ba8591efab622..1f98e137f308d 100644 --- a/shell/platform/darwin/ios/framework/Source/SemanticsObject.h +++ b/shell/platform/darwin/ios/framework/Source/SemanticsObject.h @@ -91,6 +91,7 @@ constexpr int32_t kRootNodeId = 0; uid:(int32_t)uid NS_DESIGNATED_INITIALIZER; - (BOOL)nodeWillCauseScroll:(const flutter::SemanticsNode*)node; +- (BOOL)nodeShouldTriggerAnnouncement:(const flutter::SemanticsNode*)node; - (void)collectRoutes:(NSMutableArray*)edges; - (NSString*)routeName; - (BOOL)onCustomAccessibilityAction:(FlutterCustomAccessibilityAction*)action; diff --git a/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm b/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm index 08189719d3d10..9f64a729a2dff 100644 --- a/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm +++ b/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm @@ -180,6 +180,25 @@ - (BOOL)nodeWillCauseScroll:(const flutter::SemanticsNode*)node { [self node].scrollPosition != node->scrollPosition; } +/** + * Whether calling `setSemanticsNode:` with `node` should trigger an + * announcement. + */ +- (BOOL)nodeShouldTriggerAnnouncement:(const flutter::SemanticsNode*)node { + // The node dropped the live region flag, if it ever had one. + if (!node || !node->HasFlag(flutter::SemanticsFlags::kIsLiveRegion)) { + return NO; + } + + // The node has gained a new live region flag, always announce. + if (![self node].HasFlag(flutter::SemanticsFlags::kIsLiveRegion)) { + return YES; + } + + // The label has updated, and the new node has a live region flag. + return [self node].label != node->label; +} + - (BOOL)hasChildren { if (_node.IsPlatformViewNode()) { return YES; diff --git a/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm b/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm index 21bd5611beee1..fe0e33b861590 100644 --- a/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm +++ b/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm @@ -64,4 +64,43 @@ - (void)testReplaceChildAtIndex { XCTAssertEqualObjects(parent.children, @[ child2 ]); } +- (void)testShouldTriggerAnnouncement { + fml::WeakPtrFactory factory( + new flutter::MockAccessibilityBridge()); + fml::WeakPtr bridge = factory.GetWeakPtr(); + SemanticsObject* object = [[SemanticsObject alloc] initWithBridge:bridge uid:0]; + + // Handle nil with no node set. + XCTAssertFalse([object nodeShouldTriggerAnnouncement:nil]); + + // Handle initial setting of node with liveRegion + flutter::SemanticsNode node; + node.flags = static_cast(flutter::SemanticsFlags::kIsLiveRegion); + node.label = "foo"; + XCTAssertTrue([object nodeShouldTriggerAnnouncement:&node]); + + // Handle nil with node set. + [object setSemanticsNode:&node]; + XCTAssertFalse([object nodeShouldTriggerAnnouncement:nil]); + + // Handle new node, still has live region, same label. + XCTAssertFalse([object nodeShouldTriggerAnnouncement:&node]); + + // Handle update node with new label, still has live region. + flutter::SemanticsNode updatedNode; + updatedNode.flags = static_cast(flutter::SemanticsFlags::kIsLiveRegion); + updatedNode.label = "bar"; + XCTAssertTrue([object nodeShouldTriggerAnnouncement:&updatedNode]); + + // Handle dropping the live region flag. + updatedNode.flags = 0; + XCTAssertFalse([object nodeShouldTriggerAnnouncement:&updatedNode]); + + // Handle adding the flag when the label has not changed. + updatedNode.label = "foo"; + [object setSemanticsNode:&updatedNode]; + XCTAssertTrue([object nodeShouldTriggerAnnouncement:&node]); +} + + @end diff --git a/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm b/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm index 3e1da1bdd1de1..884901b5ad179 100644 --- a/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm +++ b/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm @@ -46,6 +46,7 @@ flutter::CustomAccessibilityActionUpdates actions) { BOOL layoutChanged = NO; BOOL scrollOccured = NO; + BOOL needsAnnouncement = NO; for (const auto& entry : actions) { const flutter::CustomAccessibilityAction& action = entry.second; actions_[action.id] = action; @@ -55,6 +56,7 @@ SemanticsObject* object = GetOrCreateObject(node.id, nodes); layoutChanged = layoutChanged || [object nodeWillCauseLayoutChange:&node]; scrollOccured = scrollOccured || [object nodeWillCauseScroll:&node]; + needsAnnouncement = [object nodeShouldTriggerAnnouncement:&node]; [object setSemanticsNode:&node]; NSUInteger newChildCount = node.childrenInTraversalOrder.size(); NSMutableArray* newChildren = @@ -95,6 +97,26 @@ } else if (object.platformViewSemanticsContainer) { object.platformViewSemanticsContainer = nil; } + if (needsAnnouncement) { + // Try to be more polite - iOS 11+ supports + // UIAccessibilitySpeechAttributeQueueAnnouncement which should avoid + // interrupting system notifications or other elements. + // Expectation: roughly match the behavior of polite announcements on + // Android. + NSString* announcement = + [[[NSString alloc] initWithUTF8String:object.node.label.c_str()] autorelease]; + if (@available(iOS 11.0, *)) { + UIAccessibilityPostNotification( + UIAccessibilityAnnouncementNotification, + [[[NSAttributedString alloc] + initWithString:announcement + attributes:@{ + UIAccessibilitySpeechAttributeQueueAnnouncement : @YES + }] autorelease]); + } else { + UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, announcement); + } + } } SemanticsObject* root = objects_.get()[@(kRootNodeId)]; From ae613e0d0c2820135d6a20d86a21306663c11602 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 3 Jun 2020 14:25:57 -0700 Subject: [PATCH 2/5] update docs --- lib/ui/semantics.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/ui/semantics.dart b/lib/ui/semantics.dart index 542aba9fd862f..ec6e12ef1e401 100644 --- a/lib/ui/semantics.dart +++ b/lib/ui/semantics.dart @@ -483,9 +483,12 @@ class SemanticsFlag { /// Platforms may use this information to make polite announcements to the /// user to inform them of updates to this node. /// - /// An example of a live region is a [SnackBar] widget. On Android, A live - /// region causes a polite announcement to be generated automatically, even - /// if the user does not have focus of the widget. + /// An example of a live region is a [SnackBar] widget. On Android and iOS, + /// live region causes a polite announcement to be generated automatically, + /// even if the user does not have focus of the widget. This announcement may + /// not be spoken if the OS accessibility services are already announcing + /// something else, such as reading the label of a focused widget or providing + /// a system announcement. static const SemanticsFlag isLiveRegion = SemanticsFlag._(_kIsLiveRegionIndex); /// The semantics node has the quality of either being "on" or "off". From c5b7e8eba6a7d12ac302f7cfd9efbb2de857b16a Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 3 Jun 2020 14:56:16 -0700 Subject: [PATCH 3/5] format --- .../platform/darwin/ios/framework/Source/SemanticsObjectTest.mm | 1 - 1 file changed, 1 deletion(-) diff --git a/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm b/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm index fe0e33b861590..7ee7df2d2b7ba 100644 --- a/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm +++ b/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm @@ -102,5 +102,4 @@ - (void)testShouldTriggerAnnouncement { XCTAssertTrue([object nodeShouldTriggerAnnouncement:&node]); } - @end From 32a54c2ffa8524a1c1568db95a0a255c4fe0e675 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 3 Jun 2020 17:10:39 -0700 Subject: [PATCH 4/5] Update semantics.dart --- lib/ui/semantics.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/ui/semantics.dart b/lib/ui/semantics.dart index ec6e12ef1e401..e2729910438c6 100644 --- a/lib/ui/semantics.dart +++ b/lib/ui/semantics.dart @@ -485,10 +485,10 @@ class SemanticsFlag { /// /// An example of a live region is a [SnackBar] widget. On Android and iOS, /// live region causes a polite announcement to be generated automatically, - /// even if the user does not have focus of the widget. This announcement may - /// not be spoken if the OS accessibility services are already announcing - /// something else, such as reading the label of a focused widget or providing - /// a system announcement. + /// even if the user does not have accessibility focus of the widget. This + /// announcement may not be spoken if the OS accessibility services are + /// already announcing something else, such as reading the label of a focused + /// widget or providing a system announcement. static const SemanticsFlag isLiveRegion = SemanticsFlag._(_kIsLiveRegionIndex); /// The semantics node has the quality of either being "on" or "off". From ed5bb6b95e8e4e527b13a5da2de138da03dd5641 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 3 Jun 2020 17:12:25 -0700 Subject: [PATCH 5/5] Update semantics.dart --- lib/ui/semantics.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ui/semantics.dart b/lib/ui/semantics.dart index e2729910438c6..1769b67cac85d 100644 --- a/lib/ui/semantics.dart +++ b/lib/ui/semantics.dart @@ -485,9 +485,9 @@ class SemanticsFlag { /// /// An example of a live region is a [SnackBar] widget. On Android and iOS, /// live region causes a polite announcement to be generated automatically, - /// even if the user does not have accessibility focus of the widget. This - /// announcement may not be spoken if the OS accessibility services are - /// already announcing something else, such as reading the label of a focused + /// even if the widget does not have accessibility focus. This announcement + /// may not be spoken if the OS accessibility services are already + /// announcing something else, such as reading the label of a focused /// widget or providing a system announcement. static const SemanticsFlag isLiveRegion = SemanticsFlag._(_kIsLiveRegionIndex);