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 all 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
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class AccessibilityBridge final : public AccessibilityBridgeIos {

private:
SemanticsObject* GetOrCreateObject(int32_t id, flutter::SemanticsNodeUpdates& updates);
SemanticsObject* FindFirstFocusable(SemanticsObject* object);
void VisitObjectsRecursivelyAndRemove(SemanticsObject* object,
NSMutableArray<NSNumber*>* doomed_uids);
void HandleEvent(NSDictionary<NSString*, id>* annotatedEvent);
Expand Down
49 changes: 39 additions & 10 deletions shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm
Original file line number Diff line number Diff line change
Expand Up @@ -187,22 +187,36 @@ void PostAccessibilityNotification(UIAccessibilityNotifications notification,
[objects_ removeObjectsForKeys:doomed_uids];

layoutChanged = layoutChanged || [doomed_uids count] > 0;
// We should send out only one notification per semantics update.
if (routeChanged) {
if (!ios_delegate_->IsFlutterViewControllerPresentingModalViewController(view_controller_)) {
SemanticsObject* nextToFocus = [lastAdded routeFocusObject];
if (!nextToFocus && root) {
nextToFocus = FindFirstFocusable(root);
}
ios_delegate_->PostAccessibilityNotification(UIAccessibilityScreenChangedNotification,
[lastAdded routeFocusObject]);
nextToFocus);
}
} else if (layoutChanged) {
// Tries to refocus the previous focused semantics object to avoid random jumps.
ios_delegate_->PostAccessibilityNotification(
UIAccessibilityLayoutChangedNotification,
[objects_.get() objectForKey:@(last_focused_semantics_object_id_)]);
}
if (scrollOccured) {
// Tries to refocus the previous focused semantics object to avoid random jumps.
ios_delegate_->PostAccessibilityNotification(
UIAccessibilityPageScrolledNotification,
[objects_.get() objectForKey:@(last_focused_semantics_object_id_)]);
SemanticsObject* nextToFocus =
[objects_.get() objectForKey:@(last_focused_semantics_object_id_)];
if (!nextToFocus && root) {
nextToFocus = FindFirstFocusable(root);
}
ios_delegate_->PostAccessibilityNotification(UIAccessibilityLayoutChangedNotification,
nextToFocus);
} else if (scrollOccured) {
// TODO(chunhtai): figure out what string to use for notification. At this
// point, it is guarantee the previous focused object is still in the tree
// so that we don't need to worry about focus lost. (e.g. "Screen 0 of 3")
SemanticsObject* nextToFocus =
[objects_.get() objectForKey:@(last_focused_semantics_object_id_)];
if (!nextToFocus && root) {
nextToFocus = FindFirstFocusable(root);
}
ios_delegate_->PostAccessibilityNotification(UIAccessibilityPageScrolledNotification,
nextToFocus);
}
}

Expand Down Expand Up @@ -286,6 +300,21 @@ static bool DidFlagChange(const flutter::SemanticsNode& oldNode,
VisitObjectsRecursivelyAndRemove(child, doomed_uids);
}

SemanticsObject* AccessibilityBridge::FindFirstFocusable(SemanticsObject* object) {
if (object.isAccessibilityElement) {
return object;
}

SemanticsObject* candidate = nil;
for (SemanticsObject* child in [object children]) {
if (candidate) {
break;
}
candidate = FindFirstFocusable(child);
}
return candidate;
}

void AccessibilityBridge::HandleEvent(NSDictionary<NSString*, id>* annotatedEvent) {
NSString* type = annotatedEvent[@"type"];
if ([type isEqualToString:@"announce"]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,6 @@ - (void)testAnnouncesRouteChanges {
id mockFlutterView = OCMClassMock([FlutterView class]);
id mockFlutterViewController = OCMClassMock([FlutterViewController class]);
OCMStub([mockFlutterViewController view]).andReturn(mockFlutterView);
std::string label = "some label";

NSMutableArray<NSDictionary<NSString*, id>*>* accessibility_notifications =
[[[NSMutableArray alloc] init] autorelease];
Expand All @@ -304,24 +303,100 @@ - (void)testAnnouncesRouteChanges {
flutter::CustomAccessibilityActionUpdates actions;
flutter::SemanticsNodeUpdates nodes;

flutter::SemanticsNode route_node;
route_node.id = 1;
route_node.flags = static_cast<int32_t>(flutter::SemanticsFlags::kScopesRoute) |
static_cast<int32_t>(flutter::SemanticsFlags::kNamesRoute);
route_node.label = "route";
nodes[route_node.id] = route_node;
flutter::SemanticsNode node1;
node1.id = 1;
node1.label = "node1";
node1.flags = static_cast<int32_t>(flutter::SemanticsFlags::kScopesRoute);
node1.childrenInTraversalOrder = {2, 3};
node1.childrenInHitTestOrder = {2, 3};
nodes[node1.id] = node1;
flutter::SemanticsNode node2;
node2.id = 2;
node2.label = "node2";
nodes[node2.id] = node2;
flutter::SemanticsNode node3;
node3.id = 3;
node3.flags = static_cast<int32_t>(flutter::SemanticsFlags::kNamesRoute);
node3.label = "node3";
nodes[node3.id] = node3;
flutter::SemanticsNode root_node;
root_node.id = kRootNodeId;
root_node.label = label;
root_node.flags = static_cast<int32_t>(flutter::SemanticsFlags::kScopesRoute);
root_node.childrenInTraversalOrder = {1};
root_node.childrenInHitTestOrder = {1};
nodes[root_node.id] = root_node;
bridge->UpdateSemantics(/*nodes=*/nodes, /*actions=*/actions);

XCTAssertEqual([accessibility_notifications count], 1ul);
SemanticsObject* focusObject = accessibility_notifications[0][@"argument"];
XCTAssertEqual([focusObject uid], 1);
XCTAssertEqualObjects([focusObject accessibilityLabel], @"route");
XCTAssertEqual([focusObject uid], 3);
XCTAssertEqualObjects([focusObject accessibilityLabel], @"node3");
XCTAssertEqual([accessibility_notifications[0][@"notification"] unsignedIntValue],
UIAccessibilityScreenChangedNotification);
}

- (void)testAnnouncesRouteChangesWhenNoNamesRoute {
flutter::MockDelegate mock_delegate;
auto thread_task_runner = CreateNewThread("AccessibilityBridgeTest");
flutter::TaskRunners runners(/*label=*/self.name.UTF8String,
/*platform=*/thread_task_runner,
/*raster=*/thread_task_runner,
/*ui=*/thread_task_runner,
/*io=*/thread_task_runner);
auto platform_view = std::make_unique<flutter::PlatformViewIOS>(
/*delegate=*/mock_delegate,
/*rendering_api=*/flutter::IOSRenderingAPI::kSoftware,
/*task_runners=*/runners);
id mockFlutterView = OCMClassMock([FlutterView class]);
id mockFlutterViewController = OCMClassMock([FlutterViewController class]);
OCMStub([mockFlutterViewController view]).andReturn(mockFlutterView);

NSMutableArray<NSDictionary<NSString*, id>*>* accessibility_notifications =
[[[NSMutableArray alloc] init] autorelease];
auto ios_delegate = std::make_unique<flutter::MockIosDelegate>();
ios_delegate->on_PostAccessibilityNotification_ =
[accessibility_notifications](UIAccessibilityNotifications notification, id argument) {
[accessibility_notifications addObject:@{
@"notification" : @(notification),
@"argument" : argument ? argument : [NSNull null],
}];
};
__block auto bridge =
std::make_unique<flutter::AccessibilityBridge>(/*view_controller=*/mockFlutterViewController,
/*platform_view=*/platform_view.get(),
/*platform_views_controller=*/nil,
/*ios_delegate=*/std::move(ios_delegate));

flutter::CustomAccessibilityActionUpdates actions;
flutter::SemanticsNodeUpdates nodes;

flutter::SemanticsNode node1;
node1.id = 1;
node1.label = "node1";
node1.flags = static_cast<int32_t>(flutter::SemanticsFlags::kScopesRoute);
node1.childrenInTraversalOrder = {2, 3};
node1.childrenInHitTestOrder = {2, 3};
nodes[node1.id] = node1;
flutter::SemanticsNode node2;
node2.id = 2;
node2.label = "node2";
nodes[node2.id] = node2;
flutter::SemanticsNode node3;
node3.id = 3;
node3.label = "node3";
nodes[node3.id] = node3;
flutter::SemanticsNode root_node;
root_node.id = kRootNodeId;
root_node.childrenInTraversalOrder = {1};
root_node.childrenInHitTestOrder = {1};
nodes[root_node.id] = root_node;
bridge->UpdateSemantics(/*nodes=*/nodes, /*actions=*/actions);

// Notification should focus first focusable node, which is node1.
XCTAssertEqual([accessibility_notifications count], 1ul);
SemanticsObject* focusObject = accessibility_notifications[0][@"argument"];
XCTAssertEqual([focusObject uid], 2);
XCTAssertEqualObjects([focusObject accessibilityLabel], @"node2");
XCTAssertEqual([accessibility_notifications[0][@"notification"] unsignedIntValue],
UIAccessibilityScreenChangedNotification);
}
Expand Down Expand Up @@ -384,9 +459,10 @@ - (void)testAnnouncesLayoutChangeWithNilIfLastFocusIsRemoved {
new_root_node.label = "root";
second_update[root_node.id] = new_root_node;
bridge->UpdateSemantics(/*nodes=*/second_update, /*actions=*/actions);
NSNull* focusObject = accessibility_notifications[0][@"argument"];
// The node 1 was removed, so the bridge will set the focus object to nil.
XCTAssertEqual(focusObject, [NSNull null]);
SemanticsObject* focusObject = accessibility_notifications[0][@"argument"];
// The node 1 was removed, so the bridge will set the focus object to root.
XCTAssertEqual([focusObject uid], 0);
XCTAssertEqualObjects([focusObject accessibilityLabel], @"root");
XCTAssertEqual([accessibility_notifications[0][@"notification"] unsignedIntValue],
UIAccessibilityLayoutChangedNotification);
}
Expand Down