diff --git a/lib/ui/semantics.dart b/lib/ui/semantics.dart index 542aba9fd862f..1769b67cac85d 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 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); /// The semantics node has the quality of either being "on" or "off". 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..7ee7df2d2b7ba 100644 --- a/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm +++ b/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm @@ -64,4 +64,42 @@ - (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)];