Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions lib/ui/semantics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we say: "even if the widget does not have accessibility focus" - I think that's slightly clearer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

/// 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".
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SemanticsObject*>*)edges;
- (NSString*)routeName;
- (BOOL)onCustomAccessibilityAction:(FlutterCustomAccessibilityAction*)action;
Expand Down
19 changes: 19 additions & 0 deletions shell/platform/darwin/ios/framework/Source/SemanticsObject.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 38 additions & 0 deletions shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,42 @@ - (void)testReplaceChildAtIndex {
XCTAssertEqualObjects(parent.children, @[ child2 ]);
}

- (void)testShouldTriggerAnnouncement {
fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(
new flutter::MockAccessibilityBridge());
fml::WeakPtr<flutter::AccessibilityBridgeIos> 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<int32_t>(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<int32_t>(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
22 changes: 22 additions & 0 deletions shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 =
Expand Down Expand Up @@ -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)];
Expand Down