Flutter iOS Embedder
SemanticsObject.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 
6 
9 
11 
12 namespace {
13 
14 flutter::SemanticsAction GetSemanticsActionForScrollDirection(
15  UIAccessibilityScrollDirection direction) {
16  // To describe the vertical scroll direction, UIAccessibilityScrollDirection uses the
17  // direction the scroll bar moves in and SemanticsAction uses the direction the finger
18  // moves in. However, the horizontal scroll direction matches the SemanticsAction direction.
19  // That is way the following maps vertical opposite of the SemanticsAction, but the horizontal
20  // maps directly.
21  switch (direction) {
22  case UIAccessibilityScrollDirectionRight:
23  case UIAccessibilityScrollDirectionPrevious: // TODO(abarth): Support RTL using
24  // _node.textDirection.
25  return flutter::SemanticsAction::kScrollRight;
26  case UIAccessibilityScrollDirectionLeft:
27  case UIAccessibilityScrollDirectionNext: // TODO(abarth): Support RTL using
28  // _node.textDirection.
29  return flutter::SemanticsAction::kScrollLeft;
30  case UIAccessibilityScrollDirectionUp:
31  return flutter::SemanticsAction::kScrollDown;
32  case UIAccessibilityScrollDirectionDown:
33  return flutter::SemanticsAction::kScrollUp;
34  }
35  FML_DCHECK(false); // Unreachable
36  return flutter::SemanticsAction::kScrollUp;
37 }
38 
40  SkM44 globalTransform = [reference node].transform;
41  for (SemanticsObject* parent = [reference parent]; parent; parent = parent.parent) {
42  globalTransform = parent.node.transform * globalTransform;
43  }
44  return globalTransform;
45 }
46 
47 SkPoint ApplyTransform(SkPoint& point, const SkM44& transform) {
48  SkV4 vector = transform.map(point.x(), point.y(), 0, 1);
49  return SkPoint::Make(vector.x / vector.w, vector.y / vector.w);
50 }
51 
52 CGPoint ConvertPointToGlobal(SemanticsObject* reference, CGPoint local_point) {
53  SkM44 globalTransform = GetGlobalTransform(reference);
54  SkPoint point = SkPoint::Make(local_point.x, local_point.y);
55  point = ApplyTransform(point, globalTransform);
56  // `rect` is in the physical pixel coordinate system. iOS expects the accessibility frame in
57  // the logical pixel coordinate system. Therefore, we divide by the `scale` (pixel ratio) to
58  // convert.
59  UIScreen* screen = reference.bridge->view().window.screen;
60  // Screen can be nil if the FlutterView is covered by another native view.
61  CGFloat scale = (screen ?: UIScreen.mainScreen).scale;
62  auto result = CGPointMake(point.x() / scale, point.y() / scale);
63  return [reference.bridge->view() convertPoint:result toView:nil];
64 }
65 
66 CGRect ConvertRectToGlobal(SemanticsObject* reference, CGRect local_rect) {
67  SkM44 globalTransform = GetGlobalTransform(reference);
68 
69  SkPoint quad[4] = {
70  SkPoint::Make(local_rect.origin.x, local_rect.origin.y), // top left
71  SkPoint::Make(local_rect.origin.x + local_rect.size.width, local_rect.origin.y), // top right
72  SkPoint::Make(local_rect.origin.x + local_rect.size.width,
73  local_rect.origin.y + local_rect.size.height), // bottom right
74  SkPoint::Make(local_rect.origin.x,
75  local_rect.origin.y + local_rect.size.height) // bottom left
76  };
77  for (auto& point : quad) {
78  point = ApplyTransform(point, globalTransform);
79  }
80  SkRect rect;
81  NSCAssert(rect.setBoundsCheck({quad, 4}), @"Transformed points can't form a rect");
82  rect.setBounds({quad, 4});
83 
84  // `rect` is in the physical pixel coordinate system. iOS expects the accessibility frame in
85  // the logical pixel coordinate system. Therefore, we divide by the `scale` (pixel ratio) to
86  // convert.
87  UIScreen* screen = reference.bridge->view().window.screen;
88  // Screen can be nil if the FlutterView is covered by another native view.
89  CGFloat scale = (screen ?: UIScreen.mainScreen).scale;
90  auto result =
91  CGRectMake(rect.x() / scale, rect.y() / scale, rect.width() / scale, rect.height() / scale);
92  return UIAccessibilityConvertFrameToScreenCoordinates(result, reference.bridge->view());
93 }
94 
95 } // namespace
96 
98 @property(nonatomic, retain, readonly) UISwitch* nativeSwitch;
99 @end
100 
101 @implementation FlutterSwitchSemanticsObject
102 
103 - (instancetype)initWithBridge:(fml::WeakPtr<flutter::AccessibilityBridgeIos>)bridge
104  uid:(int32_t)uid {
105  self = [super initWithBridge:bridge uid:uid];
106  if (self) {
107  _nativeSwitch = [[UISwitch alloc] init];
108  }
109  return self;
110 }
111 
112 - (NSMethodSignature*)methodSignatureForSelector:(SEL)sel {
113  NSMethodSignature* result = [super methodSignatureForSelector:sel];
114  if (!result) {
115  result = [self.nativeSwitch methodSignatureForSelector:sel];
116  }
117  return result;
118 }
119 
120 - (void)forwardInvocation:(NSInvocation*)anInvocation {
121  anInvocation.target = self.nativeSwitch;
122  [anInvocation invoke];
123 }
124 
125 - (NSString*)accessibilityValue {
126  self.nativeSwitch.on = self.node.flags.isToggled == flutter::SemanticsTristate::kTrue ||
127  self.node.flags.isChecked == flutter::SemanticsCheckState::kTrue;
128 
129  if (![self isAccessibilityBridgeAlive]) {
130  return nil;
131  } else {
132  return self.nativeSwitch.accessibilityValue;
133  }
134 }
135 
136 - (UIAccessibilityTraits)accessibilityTraits {
137  self.nativeSwitch.enabled = self.node.flags.isEnabled == flutter::SemanticsTristate::kTrue;
138 
139  return self.nativeSwitch.accessibilityTraits;
140 }
141 
142 @end // FlutterSwitchSemanticsObject
143 
146 @end
147 
148 @implementation FlutterScrollableSemanticsObject
149 
150 - (instancetype)initWithBridge:(fml::WeakPtr<flutter::AccessibilityBridgeIos>)bridge
151  uid:(int32_t)uid {
152  self = [super initWithBridge:bridge uid:uid];
153  if (self) {
154  _scrollView = [[FlutterSemanticsScrollView alloc] initWithSemanticsObject:self];
155  [_scrollView setShowsHorizontalScrollIndicator:NO];
156  [_scrollView setShowsVerticalScrollIndicator:NO];
157  [_scrollView setContentInset:UIEdgeInsetsZero];
158  [_scrollView setContentInsetAdjustmentBehavior:UIScrollViewContentInsetAdjustmentNever];
159  [self.bridge->view() addSubview:_scrollView];
160  }
161  return self;
162 }
163 
164 - (void)dealloc {
165  [_scrollView removeFromSuperview];
166 }
167 
169  // In order to make iOS think this UIScrollView is scrollable, the following
170  // requirements must be true.
171  // 1. contentSize must be bigger than the frame size.
172  // 2. The scrollable isAccessibilityElement must return YES
173  //
174  // Once the requirements are met, the iOS uses contentOffset to determine
175  // what scroll actions are available. e.g. If the view scrolls vertically and
176  // contentOffset is 0.0, only the scroll down action is available.
177  self.scrollView.frame = self.accessibilityFrame;
178  self.scrollView.contentSize = [self contentSizeInternal];
179  // See the documentation on `isDoingSystemScrolling`.
181  [self.scrollView setContentOffset:self.contentOffsetInternal animated:NO];
182  }
183 }
184 
185 - (id)nativeAccessibility {
186  return self.scrollView;
187 }
188 
189 // private methods
190 
191 - (float)scrollExtentMax {
192  if (![self isAccessibilityBridgeAlive]) {
193  return 0.0f;
194  }
195  float scrollExtentMax = self.node.scrollExtentMax;
196  if (isnan(scrollExtentMax)) {
197  scrollExtentMax = 0.0f;
198  } else if (!isfinite(scrollExtentMax)) {
199  scrollExtentMax = kScrollExtentMaxForInf + [self scrollPosition];
200  }
201  return scrollExtentMax;
202 }
203 
204 - (float)scrollPosition {
205  if (![self isAccessibilityBridgeAlive]) {
206  return 0.0f;
207  }
208  float scrollPosition = self.node.scrollPosition;
209  if (isnan(scrollPosition)) {
210  scrollPosition = 0.0f;
211  }
212  NSCAssert(isfinite(scrollPosition), @"The scrollPosition must not be infinity");
213  return scrollPosition;
214 }
215 
216 - (CGSize)contentSizeInternal {
217  CGRect result;
218  const SkRect& rect = self.node.rect;
219 
220  if (self.node.actions & flutter::kVerticalScrollSemanticsActions) {
221  result = CGRectMake(rect.x(), rect.y(), rect.width(), rect.height() + [self scrollExtentMax]);
222  } else if (self.node.actions & flutter::kHorizontalScrollSemanticsActions) {
223  result = CGRectMake(rect.x(), rect.y(), rect.width() + [self scrollExtentMax], rect.height());
224  } else {
225  result = CGRectMake(rect.x(), rect.y(), rect.width(), rect.height());
226  }
227  return ConvertRectToGlobal(self, result).size;
228 }
229 
230 - (CGPoint)contentOffsetInternal {
231  CGPoint result;
232  CGPoint origin = self.scrollView.frame.origin;
233  const SkRect& rect = self.node.rect;
234  if (self.node.actions & flutter::kVerticalScrollSemanticsActions) {
235  result = ConvertPointToGlobal(self, CGPointMake(rect.x(), rect.y() + [self scrollPosition]));
236  } else if (self.node.actions & flutter::kHorizontalScrollSemanticsActions) {
237  result = ConvertPointToGlobal(self, CGPointMake(rect.x() + [self scrollPosition], rect.y()));
238  } else {
239  result = origin;
240  }
241  return CGPointMake(result.x - origin.x, result.y - origin.y);
242 }
243 
244 @end // FlutterScrollableSemanticsObject
245 
246 @implementation FlutterCustomAccessibilityAction {
247 }
248 @end
249 
250 @interface SemanticsObject ()
251 @property(nonatomic) SemanticsObjectContainer* container;
252 
253 /** Should only be called in conjunction with setting child/parent relationship. */
254 @property(nonatomic, weak, readwrite) SemanticsObject* parent;
255 
256 @end
257 
258 @implementation SemanticsObject {
259  NSMutableArray<SemanticsObject*>* _children;
261 }
262 
263 #pragma mark - Designated initializers
264 
265 - (instancetype)initWithBridge:(fml::WeakPtr<flutter::AccessibilityBridgeIos>)bridge
266  uid:(int32_t)uid {
267  FML_DCHECK(bridge) << "bridge must be set";
268  FML_DCHECK(uid >= kRootNodeId);
269  // Initialize with the UIView as the container.
270  // The UIView will not necessarily be accessibility parent for this object.
271  // The bridge informs the OS of the actual structure via
272  // `accessibilityContainer` and `accessibilityElementAtIndex`.
273  self = [super initWithAccessibilityContainer:bridge->view()];
274 
275  if (self) {
276  _bridge = bridge;
277  _uid = uid;
278  _children = [[NSMutableArray alloc] init];
279  _childrenInHitTestOrder = [[NSArray alloc] init];
280  }
281 
282  return self;
283 }
284 
285 - (void)dealloc {
286  // Set parent and children parents to nil explicitly in dealloc.
287  // -[UIAccessibilityElement dealloc] has in the past called into -accessibilityContainer
288  // and self.children. There have also been crashes related to iOS
289  // accessing methods during dealloc, and there's a lag before the tree changes.
290  // See https://github.com/flutter/engine/pull/4602 and
291  // https://github.com/flutter/engine/pull/27786.
292  for (SemanticsObject* child in _children) {
293  if (child.parent == self) {
294  child.parent = nil;
295  }
296  }
297  [_children removeAllObjects];
298 
299  _parent = nil;
300  _inDealloc = YES;
301 }
302 
303 #pragma mark - Semantic object property accesser
304 
305 - (void)setChildren:(NSArray<SemanticsObject*>*)children {
306  for (SemanticsObject* child in _children) {
307  if (child.parent == self) {
308  child.parent = nil;
309  }
310  }
311  _children = [children mutableCopy];
312  for (SemanticsObject* child in _children) {
313  child.parent = self;
314  }
315 }
316 
317 - (void)setChildrenInHitTestOrder:(NSArray<SemanticsObject*>*)childrenInHitTestOrder {
318  _childrenInHitTestOrder = [childrenInHitTestOrder copy];
319 }
320 
321 - (BOOL)hasChildren {
322  return [self.children count] != 0;
323 }
324 
325 #pragma mark - Semantic object method
326 
327 - (BOOL)isAccessibilityBridgeAlive {
328  return self.bridge.get() != nil;
329 }
330 
331 - (void)setSemanticsNode:(const flutter::SemanticsNode*)node {
332  _node = *node;
333 }
334 
335 - (void)accessibilityBridgeDidFinishUpdate { /* Do nothing by default */
336 }
337 
338 /**
339  * Whether calling `setSemanticsNode:` with `node` would cause a layout change.
340  */
341 - (BOOL)nodeWillCauseLayoutChange:(const flutter::SemanticsNode*)node {
342  return self.node.rect != node->rect || self.node.transform != node->transform;
343 }
344 
345 /**
346  * Whether calling `setSemanticsNode:` with `node` would cause a scroll event.
347  */
348 - (BOOL)nodeWillCauseScroll:(const flutter::SemanticsNode*)node {
349  return !isnan(self.node.scrollPosition) && !isnan(node->scrollPosition) &&
350  self.node.scrollPosition != node->scrollPosition;
351 }
352 
353 /**
354  * Whether calling `setSemanticsNode:` with `node` should trigger an
355  * announcement.
356  */
357 - (BOOL)nodeShouldTriggerAnnouncement:(const flutter::SemanticsNode*)node {
358  // The node dropped the live region flag, if it ever had one.
359  if (!node || !node->flags.isLiveRegion) {
360  return NO;
361  }
362 
363  // The node has gained a new live region flag, always announce.
364  if (!self.node.flags.isLiveRegion) {
365  return YES;
366  }
367 
368  // The label has updated, and the new node has a live region flag.
369  return self.node.label != node->label;
370 }
371 
372 - (void)replaceChildAtIndex:(NSInteger)index withChild:(SemanticsObject*)child {
373  SemanticsObject* oldChild = _children[index];
374  oldChild.parent = nil;
375  child.parent = self;
376  [_children replaceObjectAtIndex:index withObject:child];
377 }
378 
379 - (NSString*)routeName {
380  // Returns the first non-null and non-empty semantic label of a child
381  // with an NamesRoute flag. Otherwise returns nil.
382  if (self.node.flags.namesRoute) {
383  NSString* newName = self.accessibilityLabel;
384  if (newName != nil && [newName length] > 0) {
385  return newName;
386  }
387  }
388  if ([self hasChildren]) {
389  for (SemanticsObject* child in self.children) {
390  NSString* newName = [child routeName];
391  if (newName != nil && [newName length] > 0) {
392  return newName;
393  }
394  }
395  }
396  return nil;
397 }
398 
399 - (id)nativeAccessibility {
400  return self;
401 }
402 
403 - (NSAttributedString*)createAttributedStringFromString:(NSString*)string
404  withAttributes:
405  (const flutter::StringAttributes&)attributes {
406  NSMutableAttributedString* attributedString =
407  [[NSMutableAttributedString alloc] initWithString:string];
408  for (const auto& attribute : attributes) {
409  NSRange range = NSMakeRange(attribute->start, attribute->end - attribute->start);
410  switch (attribute->type) {
411  case flutter::StringAttributeType::kLocale: {
412  std::shared_ptr<flutter::LocaleStringAttribute> locale_attribute =
413  std::static_pointer_cast<flutter::LocaleStringAttribute>(attribute);
414  NSDictionary* attributeDict = @{
415  UIAccessibilitySpeechAttributeLanguage : @(locale_attribute->locale.data()),
416  };
417  [attributedString setAttributes:attributeDict range:range];
418  break;
419  }
420  case flutter::StringAttributeType::kSpellOut: {
421  NSDictionary* attributeDict = @{
422  UIAccessibilitySpeechAttributeSpellOut : @YES,
423  };
424  [attributedString setAttributes:attributeDict range:range];
425  break;
426  }
427  }
428  }
429  return attributedString;
430 }
431 
432 - (void)showOnScreen {
433  self.bridge->DispatchSemanticsAction(self.uid, flutter::SemanticsAction::kShowOnScreen);
434 }
435 
436 #pragma mark - UIAccessibility overrides
437 
438 - (BOOL)isAccessibilityElement {
439  if (![self isAccessibilityBridgeAlive]) {
440  return false;
441  }
442 
443  // Note: hit detection will only apply to elements that report
444  // -isAccessibilityElement of YES. The framework will continue scanning the
445  // entire element tree looking for such a hit.
446 
447  // We enforce in the framework that no other useful semantics are merged with these nodes.
448  if (self.node.flags.scopesRoute) {
449  return false;
450  }
451 
452  return [self isFocusable];
453 }
454 
455 - (NSString*)accessibilityLanguage {
456  if (![self isAccessibilityBridgeAlive]) {
457  return nil;
458  }
459 
460  if (!self.node.locale.empty()) {
461  return @(self.node.locale.data());
462  }
463  return self.bridge->GetDefaultLocale();
464 }
465 
466 - (bool)isFocusable {
467  // If the node is scrollable AND hidden OR
468  // The node has a label, value, or hint OR
469  // The node has non-scrolling related actions.
470  //
471  // The kIsHidden flag set with the scrollable flag means this node is now
472  // hidden but still is a valid target for a11y focus in the tree, e.g. a list
473  // item that is currently off screen but the a11y navigation needs to know
474  // about.
475  return (self.node.flags.hasImplicitScrolling && self.node.flags.isHidden)
476 
477  || !self.node.label.empty() || !self.node.value.empty() || !self.node.hint.empty() ||
478  (self.node.actions & ~flutter::kScrollableSemanticsActions) != 0;
479 }
480 
481 - (void)collectRoutes:(NSMutableArray<SemanticsObject*>*)edges {
482  if (self.node.flags.scopesRoute) {
483  [edges addObject:self];
484  }
485  if ([self hasChildren]) {
486  for (SemanticsObject* child in self.children) {
487  [child collectRoutes:edges];
488  }
489  }
490 }
491 
492 - (BOOL)onCustomAccessibilityAction:(FlutterCustomAccessibilityAction*)action {
493  if (!self.node.HasAction(flutter::SemanticsAction::kCustomAction)) {
494  return NO;
495  }
496  int32_t action_id = action.uid;
497  std::vector<uint8_t> args;
498  args.push_back(3); // type=int32.
499  args.push_back(action_id);
500  args.push_back(action_id >> 8);
501  args.push_back(action_id >> 16);
502  args.push_back(action_id >> 24);
503  self.bridge->DispatchSemanticsAction(
504  self.uid, flutter::SemanticsAction::kCustomAction,
505  fml::MallocMapping::Copy(args.data(), args.size() * sizeof(uint8_t)));
506  return YES;
507 }
508 
509 - (NSString*)accessibilityIdentifier {
510  if (![self isAccessibilityBridgeAlive]) {
511  return nil;
512  }
513 
514  if (self.node.identifier.empty()) {
515  return nil;
516  }
517  return @(self.node.identifier.data());
518 }
519 
520 - (NSString*)accessibilityLabel {
521  if (![self isAccessibilityBridgeAlive]) {
522  return nil;
523  }
524  NSString* label = nil;
525  if (!self.node.label.empty()) {
526  label = @(self.node.label.data());
527  }
528  if (!self.node.tooltip.empty()) {
529  label = label ? [NSString stringWithFormat:@"%@\n%@", label, @(self.node.tooltip.data())]
530  : @(self.node.tooltip.data());
531  }
532  return label;
533 }
534 
535 - (bool)containsPoint:(CGPoint)point {
536  // The point is in global coordinates, so use the global rect here.
537  return CGRectContainsPoint([self globalRect], point);
538 }
539 
540 // Finds the first eligiable semantics object in hit test order.
541 - (id)search:(CGPoint)point {
542  // Search children in hit test order.
543  for (SemanticsObject* child in [self childrenInHitTestOrder]) {
544  if ([child containsPoint:point]) {
545  id childSearchResult = [child search:point];
546  if (childSearchResult != nil) {
547  return childSearchResult;
548  }
549  }
550  }
551  // Check if the current semantic object should be returned.
552  if ([self containsPoint:point] && [self isFocusable]) {
553  return self.nativeAccessibility;
554  }
555  return nil;
556 }
557 
558 // iOS uses this method to determine the hittest results when users touch
559 // explore in VoiceOver.
560 //
561 // For overlapping UIAccessibilityElements (e.g. a stack) in IOS, the focus
562 // goes to the smallest object before IOS 16, but to the top-left object in
563 // IOS 16. Overrides this method to focus the first eligiable semantics
564 // object in hit test order.
565 - (id)_accessibilityHitTest:(CGPoint)point withEvent:(UIEvent*)event {
566  return [self search:point];
567 }
568 
569 // iOS calls this method when this item is swipe-to-focusd in VoiceOver.
570 - (BOOL)accessibilityScrollToVisible {
571  [self showOnScreen];
572  return YES;
573 }
574 
575 // iOS calls this method when this item is swipe-to-focusd in VoiceOver.
576 - (BOOL)accessibilityScrollToVisibleWithChild:(id)child {
577  if ([child isKindOfClass:[SemanticsObject class]]) {
578  [child showOnScreen];
579  return YES;
580  }
581  return NO;
582 }
583 
584 - (NSAttributedString*)accessibilityAttributedLabel {
585  NSString* label = self.accessibilityLabel;
586  if (label.length == 0) {
587  return nil;
588  }
589  return [self createAttributedStringFromString:label withAttributes:self.node.labelAttributes];
590 }
591 
592 - (NSString*)accessibilityHint {
593  if (![self isAccessibilityBridgeAlive]) {
594  return nil;
595  }
596 
597  if (self.node.hint.empty()) {
598  return nil;
599  }
600  return @(self.node.hint.data());
601 }
602 
603 - (NSAttributedString*)accessibilityAttributedHint {
604  NSString* hint = [self accessibilityHint];
605  if (hint.length == 0) {
606  return nil;
607  }
608  return [self createAttributedStringFromString:hint withAttributes:self.node.hintAttributes];
609 }
610 
611 - (NSString*)accessibilityValue {
612  if (![self isAccessibilityBridgeAlive]) {
613  return nil;
614  }
615 
616  if (!self.node.value.empty()) {
617  return @(self.node.value.data());
618  }
619 
620  // iOS does not announce values of native radio buttons.
621  if (self.node.flags.isInMutuallyExclusiveGroup) {
622  return nil;
623  }
624 
625  // FlutterSwitchSemanticsObject should supercede these conditionals.
626 
627  if (self.node.flags.isToggled == flutter::SemanticsTristate::kTrue ||
628  self.node.flags.isChecked == flutter::SemanticsCheckState::kTrue) {
629  return @"1";
630  } else if (self.node.flags.isToggled == flutter::SemanticsTristate::kFalse ||
631  self.node.flags.isChecked == flutter::SemanticsCheckState::kFalse) {
632  return @"0";
633  }
634 
635  return nil;
636 }
637 
638 - (NSAttributedString*)accessibilityAttributedValue {
639  NSString* value = [self accessibilityValue];
640  if (value.length == 0) {
641  return nil;
642  }
643  return [self createAttributedStringFromString:value withAttributes:self.node.valueAttributes];
644 }
645 
646 - (CGRect)accessibilityFrame {
647  if (![self isAccessibilityBridgeAlive]) {
648  return CGRectMake(0, 0, 0, 0);
649  }
650 
651  if (self.node.flags.isHidden) {
652  return [super accessibilityFrame];
653  }
654  return [self globalRect];
655 }
656 
657 - (CGRect)globalRect {
658  const SkRect& rect = self.node.rect;
659  CGRect localRect = CGRectMake(rect.x(), rect.y(), rect.width(), rect.height());
660  return ConvertRectToGlobal(self, localRect);
661 }
662 
663 #pragma mark - UIAccessibilityElement protocol
664 
665 - (void)setAccessibilityContainer:(id)container {
666  // Explicit noop. The containers are calculated lazily in `accessibilityContainer`.
667  // See also: https://github.com/flutter/flutter/issues/54366
668 }
669 
670 - (id)accessibilityContainer {
671  if (_inDealloc) {
672  // In iOS9, `accessibilityContainer` will be called by `[UIAccessibilityElementSuperCategory
673  // dealloc]` during `[super dealloc]`. And will crash when accessing `_children` which has
674  // called `[_children release]` in `[SemanticsObject dealloc]`.
675  // https://github.com/flutter/flutter/issues/87247
676  return nil;
677  }
678 
679  if (![self isAccessibilityBridgeAlive]) {
680  return nil;
681  }
682 
683  if ([self hasChildren] || self.uid == kRootNodeId) {
684  if (self.container == nil) {
685  self.container = [[SemanticsObjectContainer alloc] initWithSemanticsObject:self
686  bridge:self.bridge];
687  }
688  return self.container;
689  }
690  if (self.parent == nil) {
691  // This can happen when we have released the accessibility tree but iOS is
692  // still holding onto our objects. iOS can take some time before it
693  // realizes that the tree has changed.
694  return nil;
695  }
696  return self.parent.accessibilityContainer;
697 }
698 
699 #pragma mark - UIAccessibilityAction overrides
700 
701 - (BOOL)accessibilityActivate {
702  if (![self isAccessibilityBridgeAlive]) {
703  return NO;
704  }
705  if (!self.node.HasAction(flutter::SemanticsAction::kTap)) {
706  // Prevent sliders to receive a regular tap which will change the value.
707  //
708  // This is needed because it causes slider to select to middle if it
709  // does not have a semantics tap.
710  if (self.node.flags.isSlider) {
711  return YES;
712  }
713  return NO;
714  }
715  self.bridge->DispatchSemanticsAction(self.uid, flutter::SemanticsAction::kTap);
716  return YES;
717 }
718 
719 - (void)accessibilityIncrement {
720  if (![self isAccessibilityBridgeAlive]) {
721  return;
722  }
723  if (self.node.HasAction(flutter::SemanticsAction::kIncrease)) {
724  self.node.value = self.node.increasedValue;
725  self.bridge->DispatchSemanticsAction(self.uid, flutter::SemanticsAction::kIncrease);
726  }
727 }
728 
729 - (void)accessibilityDecrement {
730  if (![self isAccessibilityBridgeAlive]) {
731  return;
732  }
733  if (self.node.HasAction(flutter::SemanticsAction::kDecrease)) {
734  self.node.value = self.node.decreasedValue;
735  self.bridge->DispatchSemanticsAction(self.uid, flutter::SemanticsAction::kDecrease);
736  }
737 }
738 
739 - (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction {
740  if (![self isAccessibilityBridgeAlive]) {
741  return NO;
742  }
743  flutter::SemanticsAction action = GetSemanticsActionForScrollDirection(direction);
744  if (!self.node.HasAction(action)) {
745  return NO;
746  }
747  self.bridge->DispatchSemanticsAction(self.uid, action);
748  return YES;
749 }
750 
751 - (BOOL)accessibilityPerformEscape {
752  if (![self isAccessibilityBridgeAlive]) {
753  return NO;
754  }
755  if (!self.node.HasAction(flutter::SemanticsAction::kDismiss)) {
756  return NO;
757  }
758  self.bridge->DispatchSemanticsAction(self.uid, flutter::SemanticsAction::kDismiss);
759  return YES;
760 }
761 
762 #pragma mark UIAccessibilityFocus overrides
763 
764 - (void)accessibilityElementDidBecomeFocused {
765  if (![self isAccessibilityBridgeAlive]) {
766  return;
767  }
768  self.bridge->AccessibilityObjectDidBecomeFocused(self.uid);
769  if (self.node.flags.isHidden || self.node.flags.isHeader) {
770  [self showOnScreen];
771  }
772  if (self.node.HasAction(flutter::SemanticsAction::kDidGainAccessibilityFocus)) {
773  self.bridge->DispatchSemanticsAction(self.uid,
774  flutter::SemanticsAction::kDidGainAccessibilityFocus);
775  }
776 }
777 
778 - (void)accessibilityElementDidLoseFocus {
779  if (![self isAccessibilityBridgeAlive]) {
780  return;
781  }
782  self.bridge->AccessibilityObjectDidLoseFocus(self.uid);
783  if (self.node.HasAction(flutter::SemanticsAction::kDidLoseAccessibilityFocus)) {
784  self.bridge->DispatchSemanticsAction(self.uid,
785  flutter::SemanticsAction::kDidLoseAccessibilityFocus);
786  }
787 }
788 
789 - (BOOL)accessibilityRespondsToUserInteraction {
790  if (self.node.flags.isAccessibilityFocusBlocked) {
791  return false;
792  }
793 
794  // Return true only if the node contains actions other than system actions.
795  if ((self.node.actions & ~flutter::kSystemActions) != 0) {
796  return true;
797  }
798 
799  if (!self.node.customAccessibilityActions.empty()) {
800  return true;
801  }
802 
803  return false;
804 }
805 
806 @end
807 
808 @implementation FlutterSemanticsObject
809 
810 #pragma mark - Designated initializers
811 
812 - (instancetype)initWithBridge:(fml::WeakPtr<flutter::AccessibilityBridgeIos>)bridge
813  uid:(int32_t)uid {
814  self = [super initWithBridge:bridge uid:uid];
815  return self;
816 }
817 
818 #pragma mark - UIAccessibility overrides
819 
820 - (UIAccessibilityTraits)accessibilityTraits {
821  UIAccessibilityTraits traits = UIAccessibilityTraitNone;
822  if (self.node.HasAction(flutter::SemanticsAction::kIncrease) ||
823  self.node.HasAction(flutter::SemanticsAction::kDecrease)) {
824  traits |= UIAccessibilityTraitAdjustable;
825  }
826  // This should also capture radio buttons.
827  if (self.node.flags.isToggled != flutter::SemanticsTristate::kNone ||
828  self.node.flags.isChecked != flutter::SemanticsCheckState::kNone) {
829  traits |= UIAccessibilityTraitButton;
830  }
831  if (self.node.flags.isSelected == flutter::SemanticsTristate::kTrue) {
832  traits |= UIAccessibilityTraitSelected;
833  }
834  if (self.node.flags.isButton) {
835  traits |= UIAccessibilityTraitButton;
836  }
837  if (self.node.flags.isEnabled == flutter::SemanticsTristate::kFalse) {
838  traits |= UIAccessibilityTraitNotEnabled;
839  }
840  if (self.node.headingLevel > 0) {
841  // VoiceOver treats UIAccessibilityTraitHeader as heading.
842  traits |= UIAccessibilityTraitHeader;
843  }
844  if (self.node.flags.isImage) {
845  traits |= UIAccessibilityTraitImage;
846  }
847  if (self.node.flags.isLiveRegion) {
848  traits |= UIAccessibilityTraitUpdatesFrequently;
849  }
850  if (self.node.flags.isLink) {
851  traits |= UIAccessibilityTraitLink;
852  }
853  if (traits == UIAccessibilityTraitNone && ![self hasChildren] &&
854  self.accessibilityLabel.length != 0 && !self.node.flags.isTextField) {
855  traits = UIAccessibilityTraitStaticText;
856  }
857  return traits;
858 }
859 
860 @end
861 
863 @property(nonatomic, weak) UIView* platformView;
864 @end
865 
867 
868 - (instancetype)initWithBridge:(fml::WeakPtr<flutter::AccessibilityBridgeIos>)bridge
869  uid:(int32_t)uid
870  platformView:(nonnull FlutterTouchInterceptingView*)platformView {
871  if (self = [super initWithBridge:bridge uid:uid]) {
872  _platformView = platformView;
873  [platformView setFlutterAccessibilityContainer:self];
874  }
875  return self;
876 }
877 
878 - (id)nativeAccessibility {
879  return self.platformView;
880 }
881 
882 @end
883 
884 @implementation SemanticsObjectContainer {
885  fml::WeakPtr<flutter::AccessibilityBridgeIos> _bridge;
886 }
887 
888 #pragma mark - initializers
889 
890 - (instancetype)initWithSemanticsObject:(SemanticsObject*)semanticsObject
891  bridge:(fml::WeakPtr<flutter::AccessibilityBridgeIos>)bridge {
892  FML_DCHECK(semanticsObject) << "semanticsObject must be set";
893  // Initialize with the UIView as the container.
894  // The UIView will not necessarily be accessibility parent for this object.
895  // The bridge informs the OS of the actual structure via
896  // `accessibilityContainer` and `accessibilityElementAtIndex`.
897  self = [super initWithAccessibilityContainer:bridge->view()];
898 
899  if (self) {
900  _semanticsObject = semanticsObject;
901  _bridge = bridge;
902  }
903 
904  return self;
905 }
906 
907 #pragma mark - UIAccessibilityContainer overrides
908 
909 - (NSInteger)accessibilityElementCount {
910  return self.semanticsObject.children.count + 1;
911 }
912 
913 - (nullable id)accessibilityElementAtIndex:(NSInteger)index {
914  if (index < 0 || index >= [self accessibilityElementCount]) {
915  return nil;
916  }
917  if (index == 0) {
918  return self.semanticsObject.nativeAccessibility;
919  }
920 
921  SemanticsObject* child = self.semanticsObject.children[index - 1];
922 
923  if ([child hasChildren]) {
924  return child.accessibilityContainer;
925  }
926  return child.nativeAccessibility;
927 }
928 
929 - (NSInteger)indexOfAccessibilityElement:(id)element {
930  if (element == self.semanticsObject.nativeAccessibility) {
931  return 0;
932  }
933 
934  NSArray<SemanticsObject*>* children = self.semanticsObject.children;
935  for (size_t i = 0; i < [children count]; i++) {
936  SemanticsObject* child = children[i];
937  if ((![child hasChildren] && child.nativeAccessibility == element) ||
938  ([child hasChildren] && [child.nativeAccessibility accessibilityContainer] == element)) {
939  return i + 1;
940  }
941  }
942  return NSNotFound;
943 }
944 
945 #pragma mark - UIAccessibilityElement protocol
946 
947 - (BOOL)isAccessibilityElement {
948  return NO;
949 }
950 
951 - (CGRect)accessibilityFrame {
952  // For OverlayPortals, the child element is sometimes outside the bounds of the parent
953  // Even if it's marked accessible, VoiceControl labels will not appear if it's too
954  // spatially distant. Set the frame to be the max screen size so all children are guaraenteed
955  // to be contained.
956 
957  return UIScreen.mainScreen.bounds;
958 }
959 
960 - (id)accessibilityContainer {
961  if (!_bridge) {
962  return nil;
963  }
964  return ([self.semanticsObject uid] == kRootNodeId)
965  ? _bridge->view()
966  : self.semanticsObject.parent.accessibilityContainer;
967 }
968 
969 #pragma mark - UIAccessibilityAction overrides
970 
971 - (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction {
972  return [self.semanticsObject accessibilityScroll:direction];
973 }
974 
975 @end
constexpr int32_t kRootNodeId
constexpr float kScrollExtentMaxForInf
BOOL _inDealloc
FlutterSemanticsScrollView * scrollView
SemanticsObject * parent
BOOL isAccessibilityBridgeAlive()
void accessibilityBridgeDidFinishUpdate()
flutter::SemanticsNode node
NSArray< SemanticsObject * > * children
fml::WeakPtr< flutter::AccessibilityBridgeIos > bridge
CGRect ConvertRectToGlobal(SemanticsObject *reference, CGRect local_rect)
flutter::SemanticsAction GetSemanticsActionForScrollDirection(UIAccessibilityScrollDirection direction)
CGPoint ConvertPointToGlobal(SemanticsObject *reference, CGPoint local_point)
SkM44 GetGlobalTransform(SemanticsObject *reference)
SkPoint ApplyTransform(SkPoint &point, const SkM44 &transform)