| Flutter iOS Embedder |

accessibility_bridge.mm
Go to the documentation of this file.
1 // Copyright 2013 The Flutter Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4
5 #import "flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.h"
6
7 #include <utility>
8
9 #include "flutter/fml/logging.h"
10 #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h"
11 #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h"
12 #import "flutter/shell/platform/darwin/ios/framework/Source/TextInputSemanticsObject.h"
13 #import "flutter/shell/platform/darwin/ios/platform_view_ios.h"
14
15 #include "flutter/common/constants.h"
16
17 #pragma GCC diagnostic error "-Wundeclared-selector"
18
19 FLUTTER_ASSERT_ARC
20
21 namespace flutter {
22 namespace {
23
24 class DefaultIosDelegate : public AccessibilityBridge::IosDelegate {
25 public:
26 bool IsFlutterViewControllerPresentingModalViewController(
27 FlutterViewController* view_controller) override {
28 if (view_controller) {
29 return view_controller.isPresentingViewController;
30 } else {
31 return false;
32 }
33 }
34
35 void PostAccessibilityNotification(UIAccessibilityNotifications notification,
36 id argument) override {
37 UIAccessibilityPostNotification(notification, argument);
38 }
39 };
40 } // namespace
41
42 AccessibilityBridge::AccessibilityBridge(
43 FlutterViewController* view_controller,
44 PlatformViewIOS* platform_view,
45 __weak FlutterPlatformViewsController* platform_views_controller,
46 std::unique_ptr<IosDelegate> ios_delegate)
47 : view_controller_(view_controller),
48 platform_view_(platform_view),
49 platform_views_controller_(platform_views_controller),
50 objects_([[NSMutableDictionary alloc] init]),
51 previous_routes_({}),
52 ios_delegate_(ios_delegate ? std::move(ios_delegate)
53 : std::make_unique<DefaultIosDelegate>()),
54 weak_factory_(this) {
55 accessibility_channel_ = [[FlutterBasicMessageChannel alloc]
56 initWithName:@"flutter/accessibility"
57 binaryMessenger:platform_view->GetOwnerViewController().engine.binaryMessenger
58 codec:[FlutterStandardMessageCodec sharedInstance]];
59 [accessibility_channel_ setMessageHandler:^(id message, FlutterReply reply) {
60 HandleEvent((NSDictionary*)message);
61 }];
62 }
63
64 AccessibilityBridge::~AccessibilityBridge() {
65 [accessibility_channel_ setMessageHandler:nil];
66 clearState();
67 }
68
69 UIView<UITextInput>* AccessibilityBridge::textInputView() {
70 return [[platform_view_->GetOwnerViewController().engine textInputPlugin] textInputView];
71 }
72
73 void AccessibilityBridge::AccessibilityObjectDidBecomeFocused(int32_t id) {
74 last_focused_semantics_object_id_ = id;
75 [accessibility_channel_ sendMessage:@{@"type" : @"didGainFocus", @"nodeId" : @(id)}];
76 }
77
78 void AccessibilityBridge::AccessibilityObjectDidLoseFocus(int32_t id) {
79 if (last_focused_semantics_object_id_ == id) {
80 last_focused_semantics_object_id_ = kSemanticObjectIdInvalid;
81 }
82 }
83
84 void AccessibilityBridge::UpdateSemantics(
85 flutter::SemanticsNodeUpdates nodes,
86 const flutter::CustomAccessibilityActionUpdates& actions) {
87 BOOL layoutChanged = NO;
88 BOOL scrollOccured = NO;
89 BOOL needsAnnouncement = NO;
90 for (const auto& entry : actions) {
91 const flutter::CustomAccessibilityAction& action = entry.second;
92 actions_[action.id] = action;
93 }
94 for (const auto& entry : nodes) {
95 const flutter::SemanticsNode& node = entry.second;
96 SemanticsObject* object = GetOrCreateObject(node.id, nodes);
97 layoutChanged = layoutChanged || [object nodeWillCauseLayoutChange:&node];
98 scrollOccured = scrollOccured || [object nodeWillCauseScroll:&node];
99 needsAnnouncement = [object nodeShouldTriggerAnnouncement:&node];
100 [object setSemanticsNode:&node];
101 NSUInteger newChildCountInTraversalOrder = node.childrenInTraversalOrder.size();
102 NSMutableArray* newChildren =
103 [[NSMutableArray alloc] initWithCapacity:newChildCountInTraversalOrder];
104 for (NSUInteger i = 0; i < newChildCountInTraversalOrder; ++i) {
105 SemanticsObject* child = GetOrCreateObject(node.childrenInTraversalOrder[i], nodes);
106 [newChildren addObject:child];
107 }
108 NSUInteger newChildCountInHitTestOrder = node.childrenInHitTestOrder.size();
109 NSMutableArray* newChildrenInHitTestOrder =
110 [[NSMutableArray alloc] initWithCapacity:newChildCountInHitTestOrder];
111 for (NSUInteger i = 0; i < newChildCountInHitTestOrder; ++i) {
112 SemanticsObject* child = GetOrCreateObject(node.childrenInHitTestOrder[i], nodes);
113 [newChildrenInHitTestOrder addObject:child];
114 }
115 object.children = newChildren;
116 object.childrenInHitTestOrder = newChildrenInHitTestOrder;
117 if (!node.customAccessibilityActions.empty()) {
118 NSMutableArray<FlutterCustomAccessibilityAction*>* accessibilityCustomActions =
119 [[NSMutableArray alloc] init];
120 for (int32_t action_id : node.customAccessibilityActions) {
121 flutter::CustomAccessibilityAction& action = actions_[action_id];
122 if (action.overrideId != -1) {
123 // iOS does not support overriding standard actions, so we ignore any
124 // custom actions that have an override id provided.
125 continue;
126 }
127 NSString* label = @(action.label.data());
128 SEL selector = @selector(onCustomAccessibilityAction:);
129 FlutterCustomAccessibilityAction* customAction =
130 [[FlutterCustomAccessibilityAction alloc] initWithName:label
131 target:object
132 selector:selector];
133 customAction.uid = action_id;
134 [accessibilityCustomActions addObject:customAction];
135 }
136 object.accessibilityCustomActions = accessibilityCustomActions;
137 }
138
139 if (needsAnnouncement) {
140 // Try to be more polite - iOS 11+ supports
141 // UIAccessibilitySpeechAttributeQueueAnnouncement which should avoid
142 // interrupting system notifications or other elements.
143 // Expectation: roughly match the behavior of polite announcements on
144 // Android.
145 NSString* announcement = [[NSString alloc] initWithUTF8String:object.node.label.c_str()];
146 UIAccessibilityPostNotification(
147 UIAccessibilityAnnouncementNotification,
148 [[NSAttributedString alloc] initWithString:announcement
149 attributes:@{
150 UIAccessibilitySpeechAttributeQueueAnnouncement : @YES
151 }]);
152 }
153 }
154
155 SemanticsObject* root = objects_[@(kRootNodeId)];
156
157 bool routeChanged = false;
158 SemanticsObject* lastAdded = nil;
159
160 if (root) {
161 if (!view_controller_.view.accessibilityElements) {
162 view_controller_.view.accessibilityElements =
163 @[ [root accessibilityContainer] ?: [NSNull null] ];
164 }
165 NSMutableArray<SemanticsObject*>* newRoutes = [[NSMutableArray alloc] init];
166 [root collectRoutes:newRoutes];
167 // Finds the last route that is not in the previous routes.
168 for (SemanticsObject* route in newRoutes) {
169 if (std::find(previous_routes_.begin(), previous_routes_.end(), [route uid]) ==
170 previous_routes_.end()) {
171 lastAdded = route;
172 }
173 }
174 // If all the routes are in the previous route, get the last route.
175 if (lastAdded == nil && [newRoutes count] > 0) {
176 int index = [newRoutes count] - 1;
177 lastAdded = [newRoutes objectAtIndex:index];
178 }
179 // There are two cases if lastAdded != nil
180 // 1. lastAdded is not in previous routes. In this case,
181 // [lastAdded uid] != previous_route_id_
182 // 2. All new routes are in previous routes and
183 // lastAdded = newRoutes.last.
184 // In the first case, we need to announce new route. In the second case,
185 // we need to announce if one list is shorter than the other.
186 if (lastAdded != nil &&
187 ([lastAdded uid] != previous_route_id_ || [newRoutes count] != previous_routes_.size())) {
188 previous_route_id_ = [lastAdded uid];
189 routeChanged = true;
190 }
191 previous_routes_.clear();
192 for (SemanticsObject* route in newRoutes) {
193 previous_routes_.push_back([route uid]);
194 }
195 } else {
196 view_controller_.viewIfLoaded.accessibilityElements = nil;
197 }
198
199 NSMutableArray<NSNumber*>* doomed_uids = [NSMutableArray arrayWithArray:objects_.allKeys];
200 if (root) {
201 VisitObjectsRecursivelyAndRemove(root, doomed_uids);
202 }
203 [objects_ removeObjectsForKeys:doomed_uids];
204
205 for (SemanticsObject* object in objects_.allValues) {
206 [object accessibilityBridgeDidFinishUpdate];
207 }
208
209 if (!ios_delegate_->IsFlutterViewControllerPresentingModalViewController(view_controller_)) {
210 layoutChanged = layoutChanged || [doomed_uids count] > 0;
211
212 if (routeChanged) {
213 NSString* routeName = [lastAdded routeName];
214 ios_delegate_->PostAccessibilityNotification(UIAccessibilityScreenChangedNotification,
215 routeName);
216 }
217
218 if (layoutChanged) {
219 SemanticsObject* next = FindNextFocusableIfNecessary();
220 SemanticsObject* lastFocused = [objects_ objectForKey:@(last_focused_semantics_object_id_)];
221 // Only specify the focus item if the new focus is different, avoiding double focuses on the
222 // same item. See: https://github.com/flutter/flutter/issues/104176. If there is a route
223 // change, we always refocus.
224 ios_delegate_->PostAccessibilityNotification(
225 UIAccessibilityLayoutChangedNotification,
226 (routeChanged || next != lastFocused) ? next.nativeAccessibility : NULL);
227 } else if (scrollOccured) {
228 // TODO(chunhtai): figure out what string to use for notification. At this
229 // point, it is guarantee the previous focused object is still in the tree
230 // so that we don't need to worry about focus lost. (e.g. "Screen 0 of 3")
231 ios_delegate_->PostAccessibilityNotification(
232 UIAccessibilityPageScrolledNotification,
233 FindNextFocusableIfNecessary().nativeAccessibility);
234 }
235 }
236 }
237
238 void AccessibilityBridge::DispatchSemanticsAction(int32_t node_uid,
239 flutter::SemanticsAction action) {
240 // TODO(team-ios): Remove implicit view assumption.
241 // https://github.com/flutter/flutter/issues/142845
242 platform_view_->DispatchSemanticsAction(kFlutterImplicitViewId, node_uid, action, {});
243 }
244
245 void AccessibilityBridge::DispatchSemanticsAction(int32_t node_uid,
246 flutter::SemanticsAction action,
247 fml::MallocMapping args) {
248 // TODO(team-ios): Remove implicit view assumption.
249 // https://github.com/flutter/flutter/issues/142845
250 platform_view_->DispatchSemanticsAction(kFlutterImplicitViewId, node_uid, action,
251 std::move(args));
252 }
253
254 static void ReplaceSemanticsObject(SemanticsObject* oldObject,
255 SemanticsObject* newObject,
256 NSMutableDictionary<NSNumber*, SemanticsObject*>* objects) {
257 // `newObject` should represent the same id as `oldObject`.
258 FML_DCHECK(oldObject.node.id == newObject.uid);
259 NSNumber* nodeId = @(oldObject.node.id);
260 NSUInteger positionInChildlist = [oldObject.parent.children indexOfObject:oldObject];
261 oldObject.children = @[];
262 [oldObject.parent replaceChildAtIndex:positionInChildlist withChild:newObject];
263 [objects removeObjectForKey:nodeId];
264 objects[nodeId] = newObject;
265 }
266
267 static SemanticsObject* CreateObject(const flutter::SemanticsNode& node,
268 const fml::WeakPtr<AccessibilityBridge>& weak_ptr) {
269 if (node.flags.isTextField && !node.flags.isReadOnly) {
270 // Text fields are backed by objects that implement UITextInput.
271 return [[TextInputSemanticsObject alloc] initWithBridge:weak_ptr uid:node.id];
272 } else if (!node.flags.isInMutuallyExclusiveGroup &&
273 (node.flags.isToggled != flutter::SemanticsTristate::kNone ||
274 node.flags.isChecked != flutter::SemanticsCheckState::kNone)) {
275 return [[FlutterSwitchSemanticsObject alloc] initWithBridge:weak_ptr uid:node.id];
276 } else if (node.flags.hasImplicitScrolling) {
277 return [[FlutterScrollableSemanticsObject alloc] initWithBridge:weak_ptr uid:node.id];
278 } else if (node.IsPlatformViewNode()) {
279 FlutterPlatformViewsController* platformViewsController =
280 weak_ptr->GetPlatformViewsController();
281 FlutterTouchInterceptingView* touchInterceptingView =
282 [platformViewsController flutterTouchInterceptingViewForId:node.platformViewId];
283 return [[FlutterPlatformViewSemanticsContainer alloc] initWithBridge:weak_ptr
284 uid:node.id
285 platformView:touchInterceptingView];
286 } else {
287 return [[FlutterSemanticsObject alloc] initWithBridge:weak_ptr uid:node.id];
288 }
289 }
290
291 SemanticsObject* AccessibilityBridge::GetOrCreateObject(int32_t uid,
292 flutter::SemanticsNodeUpdates& updates) {
293 SemanticsObject* object = objects_[@(uid)];
294 if (!object) {
295 object = CreateObject(updates[uid], GetWeakPtr());
296 objects_[@(uid)] = object;
297 } else {
298 // Existing node case
299 auto nodeEntry = updates.find(object.node.id);
300 if (nodeEntry != updates.end()) {
301 // There's an update for this node
302 flutter::SemanticsNode node = nodeEntry->second;
303 if (object.node.flags.isTextField != node.flags.isTextField ||
304 object.node.flags.isReadOnly != node.flags.isReadOnly ||
305 (object.node.flags.isChecked == flutter::SemanticsCheckState::kNone) !=
306 (node.flags.isChecked == flutter::SemanticsCheckState::kNone) ||
307 ((object.node.flags.isToggled == flutter::SemanticsTristate::kNone) !=
308 (node.flags.isToggled == flutter::SemanticsTristate::kNone)) ||
309 object.node.flags.hasImplicitScrolling != node.flags.hasImplicitScrolling
310
311 ) {
312 // The node changed its type. In this case, we cannot reuse the existing
313 // SemanticsObject implementation. Instead, we replace it with a new
314 // instance.
315 SemanticsObject* newSemanticsObject = CreateObject(node, GetWeakPtr());
316 ReplaceSemanticsObject(object, newSemanticsObject, objects_);
317 object = newSemanticsObject;
318 }
319 }
320 }
321 return object;
322 }
323
324 void AccessibilityBridge::VisitObjectsRecursivelyAndRemove(SemanticsObject* object,
325 NSMutableArray<NSNumber*>* doomed_uids) {
326 [doomed_uids removeObject:@(object.uid)];
327 for (SemanticsObject* child in [object children])
328 VisitObjectsRecursivelyAndRemove(child, doomed_uids);
329 }
330
331 SemanticsObject* AccessibilityBridge::FindNextFocusableIfNecessary() {
332 // This property will be -1 if the focus is outside of the flutter
333 // application. In this case, we should not refocus anything.
334 if (last_focused_semantics_object_id_ == kSemanticObjectIdInvalid) {
335 return nil;
336 }
337
338 // Tries to refocus the previous focused semantics object to avoid random jumps.
339 return FindFirstFocusable(objects_[@(last_focused_semantics_object_id_)]);
340 }
341
342 SemanticsObject* AccessibilityBridge::FindFirstFocusable(SemanticsObject* parent) {
343 SemanticsObject* currentObject = parent ?: objects_[@(kRootNodeId)];
344 if (!currentObject) {
345 return nil;
346 }
347 if (currentObject.isAccessibilityElement) {
348 return currentObject;
349 }
350
351 for (SemanticsObject* child in [currentObject children]) {
352 SemanticsObject* candidate = FindFirstFocusable(child);
353 if (candidate) {
354 return candidate;
355 }
356 }
357 return nil;
358 }
359
360 void AccessibilityBridge::HandleEvent(NSDictionary<NSString*, id>* annotatedEvent) {
361 NSString* type = annotatedEvent[@"type"];
362 if ([type isEqualToString:@"announce"]) {
363 NSString* message = annotatedEvent[@"data"][@"message"];
364 ios_delegate_->PostAccessibilityNotification(UIAccessibilityAnnouncementNotification, message);
365 }
366 if ([type isEqualToString:@"focus"]) {
367 SemanticsObject* node = objects_[annotatedEvent[@"nodeId"]];
368 ios_delegate_->PostAccessibilityNotification(UIAccessibilityLayoutChangedNotification, node);
369 }
370 }
371
372 fml::WeakPtr<AccessibilityBridge> AccessibilityBridge::GetWeakPtr() {
373 return weak_factory_.GetWeakPtr();
374 }
375
376 void AccessibilityBridge::clearState() {
377 [objects_ removeAllObjects];
378 previous_route_id_ = 0;
379 previous_routes_.clear();
380 view_controller_.viewIfLoaded.accessibilityElements = nil;
381 }
382
383 } // namespace flutter
FlutterReply
NS_ASSUME_NONNULL_BEGIN typedef void(^ FlutterReply)(id _Nullable reply)
FlutterEngine_Internal.h
platform_view
std::unique_ptr< flutter::PlatformViewIOS > platform_view
Definition: FlutterEnginePlatformViewTest.mm:68
textInputPlugin
FlutterTextInputPlugin * textInputPlugin
Definition: FlutterTextInputPluginTest.mm:138
FlutterViewController_Internal.h
kRootNodeId
constexpr int32_t kRootNodeId
Definition: SemanticsObject.h:16
TextInputSemanticsObject.h
FlutterTouchInterceptingView
Definition: FlutterPlatformViews.mm:513
flutter::AccessibilityBridge::AccessibilityBridge
AccessibilityBridge()
Creates a new instance of a accessibility bridge.
Definition: accessibility_bridge.cc:23
accessibility_bridge.h
FlutterBasicMessageChannel
Definition: FlutterChannels.h:38
FlutterCustomAccessibilityAction
Definition: SemanticsObject.h:136
FlutterCustomAccessibilityAction::uid
int32_t uid
Definition: SemanticsObject.h:140
FlutterPlatformViewSemanticsContainer
Definition: SemanticsObject.h:170
FlutterPlatformViewsController
Definition: FlutterPlatformViewsController.h:31
FlutterScrollableSemanticsObject
Definition: SemanticsObject.h:190
FlutterSemanticsObject
Definition: SemanticsObject.h:156
FlutterStandardMessageCodec
Definition: FlutterCodecs.h:209
FlutterSwitchSemanticsObject
Definition: SemanticsObject.h:184
FlutterViewController
Definition: FlutterViewController.h:57
SemanticsObject
Definition: SemanticsObject.h:32
SemanticsObject::parent
SemanticsObject * parent
Definition: SemanticsObject.h:42
SemanticsObject::nativeAccessibility
id nativeAccessibility
Definition: SemanticsObject.h:83
SemanticsObject::uid
int32_t uid
Definition: SemanticsObject.h:36
SemanticsObject::node
flutter::SemanticsNode node
Definition: SemanticsObject.h:57
SemanticsObject::children
NSArray< SemanticsObject * > * children
Definition: SemanticsObject.h:68
TextInputSemanticsObject
Definition: TextInputSemanticsObject.h:20
FLUTTER_ASSERT_ARC
Definition: FlutterChannelKeyResponder.mm:13
flutter
Definition: accessibility_bridge.h:26
platform_view_ios.h