Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 343af33

Browse files
authored
Live region announcements for iOS (#18798)
1 parent a95882b commit 343af33

File tree

5 files changed

+86
-3
lines changed

5 files changed

+86
-3
lines changed

lib/ui/semantics.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -483,9 +483,12 @@ class SemanticsFlag {
483483
/// Platforms may use this information to make polite announcements to the
484484
/// user to inform them of updates to this node.
485485
///
486-
/// An example of a live region is a [SnackBar] widget. On Android, A live
487-
/// region causes a polite announcement to be generated automatically, even
488-
/// if the user does not have focus of the widget.
486+
/// An example of a live region is a [SnackBar] widget. On Android and iOS,
487+
/// live region causes a polite announcement to be generated automatically,
488+
/// even if the widget does not have accessibility focus. This announcement
489+
/// may not be spoken if the OS accessibility services are already
490+
/// announcing something else, such as reading the label of a focused
491+
/// widget or providing a system announcement.
489492
static const SemanticsFlag isLiveRegion = SemanticsFlag._(_kIsLiveRegionIndex);
490493

491494
/// The semantics node has the quality of either being "on" or "off".

shell/platform/darwin/ios/framework/Source/SemanticsObject.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ constexpr int32_t kRootNodeId = 0;
9191
uid:(int32_t)uid NS_DESIGNATED_INITIALIZER;
9292

9393
- (BOOL)nodeWillCauseScroll:(const flutter::SemanticsNode*)node;
94+
- (BOOL)nodeShouldTriggerAnnouncement:(const flutter::SemanticsNode*)node;
9495
- (void)collectRoutes:(NSMutableArray<SemanticsObject*>*)edges;
9596
- (NSString*)routeName;
9697
- (BOOL)onCustomAccessibilityAction:(FlutterCustomAccessibilityAction*)action;

shell/platform/darwin/ios/framework/Source/SemanticsObject.mm

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,25 @@ - (BOOL)nodeWillCauseScroll:(const flutter::SemanticsNode*)node {
180180
[self node].scrollPosition != node->scrollPosition;
181181
}
182182

183+
/**
184+
* Whether calling `setSemanticsNode:` with `node` should trigger an
185+
* announcement.
186+
*/
187+
- (BOOL)nodeShouldTriggerAnnouncement:(const flutter::SemanticsNode*)node {
188+
// The node dropped the live region flag, if it ever had one.
189+
if (!node || !node->HasFlag(flutter::SemanticsFlags::kIsLiveRegion)) {
190+
return NO;
191+
}
192+
193+
// The node has gained a new live region flag, always announce.
194+
if (![self node].HasFlag(flutter::SemanticsFlags::kIsLiveRegion)) {
195+
return YES;
196+
}
197+
198+
// The label has updated, and the new node has a live region flag.
199+
return [self node].label != node->label;
200+
}
201+
183202
- (BOOL)hasChildren {
184203
if (_node.IsPlatformViewNode()) {
185204
return YES;

shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,42 @@ - (void)testReplaceChildAtIndex {
6464
XCTAssertEqualObjects(parent.children, @[ child2 ]);
6565
}
6666

67+
- (void)testShouldTriggerAnnouncement {
68+
fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(
69+
new flutter::MockAccessibilityBridge());
70+
fml::WeakPtr<flutter::AccessibilityBridgeIos> bridge = factory.GetWeakPtr();
71+
SemanticsObject* object = [[SemanticsObject alloc] initWithBridge:bridge uid:0];
72+
73+
// Handle nil with no node set.
74+
XCTAssertFalse([object nodeShouldTriggerAnnouncement:nil]);
75+
76+
// Handle initial setting of node with liveRegion
77+
flutter::SemanticsNode node;
78+
node.flags = static_cast<int32_t>(flutter::SemanticsFlags::kIsLiveRegion);
79+
node.label = "foo";
80+
XCTAssertTrue([object nodeShouldTriggerAnnouncement:&node]);
81+
82+
// Handle nil with node set.
83+
[object setSemanticsNode:&node];
84+
XCTAssertFalse([object nodeShouldTriggerAnnouncement:nil]);
85+
86+
// Handle new node, still has live region, same label.
87+
XCTAssertFalse([object nodeShouldTriggerAnnouncement:&node]);
88+
89+
// Handle update node with new label, still has live region.
90+
flutter::SemanticsNode updatedNode;
91+
updatedNode.flags = static_cast<int32_t>(flutter::SemanticsFlags::kIsLiveRegion);
92+
updatedNode.label = "bar";
93+
XCTAssertTrue([object nodeShouldTriggerAnnouncement:&updatedNode]);
94+
95+
// Handle dropping the live region flag.
96+
updatedNode.flags = 0;
97+
XCTAssertFalse([object nodeShouldTriggerAnnouncement:&updatedNode]);
98+
99+
// Handle adding the flag when the label has not changed.
100+
updatedNode.label = "foo";
101+
[object setSemanticsNode:&updatedNode];
102+
XCTAssertTrue([object nodeShouldTriggerAnnouncement:&node]);
103+
}
104+
67105
@end

shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ void PostAccessibilityNotification(UIAccessibilityNotifications notification,
8484
flutter::CustomAccessibilityActionUpdates actions) {
8585
BOOL layoutChanged = NO;
8686
BOOL scrollOccured = NO;
87+
BOOL needsAnnouncement = NO;
8788
for (const auto& entry : actions) {
8889
const flutter::CustomAccessibilityAction& action = entry.second;
8990
actions_[action.id] = action;
@@ -93,6 +94,7 @@ void PostAccessibilityNotification(UIAccessibilityNotifications notification,
9394
SemanticsObject* object = GetOrCreateObject(node.id, nodes);
9495
layoutChanged = layoutChanged || [object nodeWillCauseLayoutChange:&node];
9596
scrollOccured = scrollOccured || [object nodeWillCauseScroll:&node];
97+
needsAnnouncement = [object nodeShouldTriggerAnnouncement:&node];
9698
[object setSemanticsNode:&node];
9799
NSUInteger newChildCount = node.childrenInTraversalOrder.size();
98100
NSMutableArray* newChildren =
@@ -133,6 +135,26 @@ void PostAccessibilityNotification(UIAccessibilityNotifications notification,
133135
} else if (object.platformViewSemanticsContainer) {
134136
object.platformViewSemanticsContainer = nil;
135137
}
138+
if (needsAnnouncement) {
139+
// Try to be more polite - iOS 11+ supports
140+
// UIAccessibilitySpeechAttributeQueueAnnouncement which should avoid
141+
// interrupting system notifications or other elements.
142+
// Expectation: roughly match the behavior of polite announcements on
143+
// Android.
144+
NSString* announcement =
145+
[[[NSString alloc] initWithUTF8String:object.node.label.c_str()] autorelease];
146+
if (@available(iOS 11.0, *)) {
147+
UIAccessibilityPostNotification(
148+
UIAccessibilityAnnouncementNotification,
149+
[[[NSAttributedString alloc]
150+
initWithString:announcement
151+
attributes:@{
152+
UIAccessibilitySpeechAttributeQueueAnnouncement : @YES
153+
}] autorelease]);
154+
} else {
155+
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, announcement);
156+
}
157+
}
136158
}
137159

138160
SemanticsObject* root = objects_.get()[@(kRootNodeId)];

0 commit comments

Comments
 (0)