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

Commit 7aa61d6

Browse files
authored
Reland ios accessibility scrolling support (#26860)
1 parent 99b752e commit 7aa61d6

File tree

5 files changed

+455
-28
lines changed

5 files changed

+455
-28
lines changed

lib/ui/semantics/semantics_node.h

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,17 @@ enum class SemanticsAction : int32_t {
4444
kSetText = 1 << 21,
4545
};
4646

47-
const int kScrollableSemanticsActions =
48-
static_cast<int32_t>(SemanticsAction::kScrollLeft) |
49-
static_cast<int32_t>(SemanticsAction::kScrollRight) |
47+
const int kVerticalScrollSemanticsActions =
5048
static_cast<int32_t>(SemanticsAction::kScrollUp) |
5149
static_cast<int32_t>(SemanticsAction::kScrollDown);
5250

51+
const int kHorizontalScrollSemanticsActions =
52+
static_cast<int32_t>(SemanticsAction::kScrollLeft) |
53+
static_cast<int32_t>(SemanticsAction::kScrollRight);
54+
55+
const int kScrollableSemanticsActions =
56+
kVerticalScrollSemanticsActions | kHorizontalScrollSemanticsActions;
57+
5358
/// C/C++ representation of `SemanticsFlags` defined in
5459
/// `lib/ui/semantics.dart`.
5560
///\warning This must match the `SemanticsFlags` enum in

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
#import "flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_ios.h"
1414

1515
constexpr int32_t kRootNodeId = 0;
16+
// This can be arbitrary number as long as it is bigger than 0.
17+
constexpr float kScrollExtentMaxForInf = 1000;
1618

1719
@class FlutterCustomAccessibilityAction;
1820
@class FlutterPlatformViewSemanticsContainer;
@@ -31,7 +33,7 @@ constexpr int32_t kRootNodeId = 0;
3133
* The parent of this node in the node tree. Will be nil for the root node and
3234
* during transient state changes.
3335
*/
34-
@property(nonatomic, readonly) SemanticsObject* parent;
36+
@property(nonatomic, assign) SemanticsObject* parent;
3537

3638
/**
3739
* The accessibility bridge that this semantics object is attached to. This
@@ -94,6 +96,14 @@ constexpr int32_t kRootNodeId = 0;
9496

9597
- (BOOL)onCustomAccessibilityAction:(FlutterCustomAccessibilityAction*)action;
9698

99+
/**
100+
* Called after accessibility bridge finishes a semantics update.
101+
*
102+
* Subclasses can override this method if they contain states that can only be
103+
* updated once every node in the accessibility tree has finished updating.
104+
*/
105+
- (void)accessibilityBridgeDidFinishUpdate;
106+
97107
#pragma mark - Designated initializers
98108

99109
- (instancetype)init __attribute__((unavailable("Use initWithBridge instead")));
@@ -159,6 +169,18 @@ constexpr int32_t kRootNodeId = 0;
159169

160170
@end
161171

172+
/// The semantics object for scrollable. This class creates an UIScrollView to interact with the
173+
/// iOS.
174+
@interface FlutterScrollableSemanticsObject : UIScrollView
175+
176+
- (instancetype)init NS_UNAVAILABLE;
177+
- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE;
178+
- (instancetype)initWithCoder:(NSCoder*)coder NS_UNAVAILABLE;
179+
- (instancetype)initWithSemanticsObject:(SemanticsObject*)semanticsObject NS_DESIGNATED_INITIALIZER;
180+
- (void)accessibilityBridgeDidFinishUpdate;
181+
182+
@end
183+
162184
/**
163185
* Represents a semantics object that has children and hence has to be presented to the OS as a
164186
* UIAccessibilityContainer.

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

Lines changed: 227 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,58 @@
3434
return flutter::SemanticsAction::kScrollUp;
3535
}
3636

37+
SkM44 GetGlobalTransform(SemanticsObject* reference) {
38+
SkM44 globalTransform = [reference node].transform;
39+
for (SemanticsObject* parent = [reference parent]; parent; parent = parent.parent) {
40+
globalTransform = parent.node.transform * globalTransform;
41+
}
42+
return globalTransform;
43+
}
44+
45+
SkPoint ApplyTransform(SkPoint& point, const SkM44& transform) {
46+
SkV4 vector = transform.map(point.x(), point.y(), 0, 1);
47+
return SkPoint::Make(vector.x / vector.w, vector.y / vector.w);
48+
}
49+
50+
CGPoint ConvertPointToGlobal(SemanticsObject* reference, CGPoint local_point) {
51+
SkM44 globalTransform = GetGlobalTransform(reference);
52+
SkPoint point = SkPoint::Make(local_point.x, local_point.y);
53+
point = ApplyTransform(point, globalTransform);
54+
// `rect` is in the physical pixel coordinate system. iOS expects the accessibility frame in
55+
// the logical pixel coordinate system. Therefore, we divide by the `scale` (pixel ratio) to
56+
// convert.
57+
CGFloat scale = [[[reference bridge]->view() window] screen].scale;
58+
auto result = CGPointMake(point.x() / scale, point.y() / scale);
59+
return [[reference bridge]->view() convertPoint:result toView:nil];
60+
}
61+
62+
CGRect ConvertRectToGlobal(SemanticsObject* reference, CGRect local_rect) {
63+
SkM44 globalTransform = GetGlobalTransform(reference);
64+
65+
SkPoint quad[4] = {
66+
SkPoint::Make(local_rect.origin.x, local_rect.origin.y), // top left
67+
SkPoint::Make(local_rect.origin.x + local_rect.size.width, local_rect.origin.y), // top right
68+
SkPoint::Make(local_rect.origin.x + local_rect.size.width,
69+
local_rect.origin.y + local_rect.size.height), // bottom right
70+
SkPoint::Make(local_rect.origin.x,
71+
local_rect.origin.y + local_rect.size.height) // bottom left
72+
};
73+
for (auto& point : quad) {
74+
point = ApplyTransform(point, globalTransform);
75+
}
76+
SkRect rect;
77+
NSCAssert(rect.setBoundsCheck(quad, 4), @"Transformed points can't form a rect");
78+
rect.setBounds(quad, 4);
79+
80+
// `rect` is in the physical pixel coordinate system. iOS expects the accessibility frame in
81+
// the logical pixel coordinate system. Therefore, we divide by the `scale` (pixel ratio) to
82+
// convert.
83+
CGFloat scale = [[[reference bridge]->view() window] screen].scale;
84+
auto result =
85+
CGRectMake(rect.x() / scale, rect.y() / scale, rect.width() / scale, rect.height() / scale);
86+
return UIAccessibilityConvertFrameToScreenCoordinates(result, [reference bridge]->view());
87+
}
88+
3789
} // namespace
3890

3991
@implementation FlutterSwitchSemanticsObject {
@@ -88,6 +140,175 @@ - (UIAccessibilityTraits)accessibilityTraits {
88140

89141
@end // FlutterSwitchSemanticsObject
90142

143+
@interface FlutterScrollableSemanticsObject ()
144+
@property(nonatomic, strong) SemanticsObject* semanticsObject;
145+
@end
146+
147+
@implementation FlutterScrollableSemanticsObject {
148+
fml::scoped_nsobject<SemanticsObjectContainer> _container;
149+
}
150+
151+
- (instancetype)initWithSemanticsObject:(SemanticsObject*)semanticsObject {
152+
self = [super initWithFrame:CGRectZero];
153+
if (self) {
154+
_semanticsObject = [semanticsObject retain];
155+
[semanticsObject.bridge->view() addSubview:self];
156+
}
157+
return self;
158+
}
159+
160+
- (void)dealloc {
161+
_container.get().semanticsObject = nil;
162+
[_semanticsObject release];
163+
[self removeFromSuperview];
164+
[super dealloc];
165+
}
166+
167+
- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event {
168+
return nil;
169+
}
170+
171+
- (NSMethodSignature*)methodSignatureForSelector:(SEL)sel {
172+
NSMethodSignature* result = [super methodSignatureForSelector:sel];
173+
if (!result) {
174+
result = [_semanticsObject methodSignatureForSelector:sel];
175+
}
176+
return result;
177+
}
178+
179+
- (void)forwardInvocation:(NSInvocation*)anInvocation {
180+
[anInvocation setTarget:_semanticsObject];
181+
[anInvocation invoke];
182+
}
183+
184+
- (void)accessibilityBridgeDidFinishUpdate {
185+
// In order to make iOS think this UIScrollView is scrollable, the following
186+
// requirements must be true.
187+
// 1. contentSize must be bigger than the frame size.
188+
// 2. The scrollable isAccessibilityElement must return YES
189+
//
190+
// Once the requirements are met, the iOS uses contentOffset to determine
191+
// what scroll actions are available. e.g. If the view scrolls vertically and
192+
// contentOffset is 0.0, only the scroll down action is available.
193+
[self setFrame:[_semanticsObject accessibilityFrame]];
194+
[self setContentSize:[self contentSizeInternal]];
195+
[self setContentOffset:[self contentOffsetInternal] animated:NO];
196+
if (self.contentSize.width > self.frame.size.width ||
197+
self.contentSize.height > self.frame.size.height) {
198+
self.isAccessibilityElement = YES;
199+
} else {
200+
self.isAccessibilityElement = NO;
201+
}
202+
}
203+
204+
- (void)setChildren:(NSArray<SemanticsObject*>*)children {
205+
[_semanticsObject setChildren:children];
206+
// The children's parent is pointing to _semanticsObject, need to manually
207+
// set it this object.
208+
for (SemanticsObject* child in _semanticsObject.children) {
209+
child.parent = (SemanticsObject*)self;
210+
}
211+
}
212+
213+
- (id)accessibilityContainer {
214+
if (_container == nil) {
215+
_container.reset([[SemanticsObjectContainer alloc]
216+
initWithSemanticsObject:(SemanticsObject*)self
217+
bridge:[_semanticsObject bridge]]);
218+
}
219+
return _container.get();
220+
}
221+
222+
// private methods
223+
224+
- (float)scrollExtentMax {
225+
if (![_semanticsObject isAccessibilityBridgeAlive]) {
226+
return 0.0f;
227+
}
228+
float scrollExtentMax = _semanticsObject.node.scrollExtentMax;
229+
if (isnan(scrollExtentMax)) {
230+
scrollExtentMax = 0.0f;
231+
} else if (!isfinite(scrollExtentMax)) {
232+
scrollExtentMax = kScrollExtentMaxForInf + [self scrollPosition];
233+
}
234+
return scrollExtentMax;
235+
}
236+
237+
- (float)scrollPosition {
238+
if (![_semanticsObject isAccessibilityBridgeAlive]) {
239+
return 0.0f;
240+
}
241+
float scrollPosition = _semanticsObject.node.scrollPosition;
242+
if (isnan(scrollPosition)) {
243+
scrollPosition = 0.0f;
244+
}
245+
NSCAssert(isfinite(scrollPosition), @"The scrollPosition must not be infinity");
246+
return scrollPosition;
247+
}
248+
249+
- (CGSize)contentSizeInternal {
250+
CGRect result;
251+
const SkRect& rect = _semanticsObject.node.rect;
252+
253+
if (_semanticsObject.node.actions & flutter::kVerticalScrollSemanticsActions) {
254+
result = CGRectMake(rect.x(), rect.y(), rect.width(), rect.height() + [self scrollExtentMax]);
255+
} else if (_semanticsObject.node.actions & flutter::kHorizontalScrollSemanticsActions) {
256+
result = CGRectMake(rect.x(), rect.y(), rect.width() + [self scrollExtentMax], rect.height());
257+
} else {
258+
result = CGRectMake(rect.x(), rect.y(), rect.width(), rect.height());
259+
}
260+
return ConvertRectToGlobal(_semanticsObject, result).size;
261+
}
262+
263+
- (CGPoint)contentOffsetInternal {
264+
CGPoint result;
265+
CGPoint origin = self.frame.origin;
266+
const SkRect& rect = _semanticsObject.node.rect;
267+
if (_semanticsObject.node.actions & flutter::kVerticalScrollSemanticsActions) {
268+
result = ConvertPointToGlobal(_semanticsObject,
269+
CGPointMake(rect.x(), rect.y() + [self scrollPosition]));
270+
} else if (_semanticsObject.node.actions & flutter::kHorizontalScrollSemanticsActions) {
271+
result = ConvertPointToGlobal(_semanticsObject,
272+
CGPointMake(rect.x() + [self scrollPosition], rect.y()));
273+
} else {
274+
result = origin;
275+
}
276+
return CGPointMake(result.x - origin.x, result.y - origin.y);
277+
}
278+
279+
// The following methods are explicitly forwarded to the wrapped SemanticsObject because the
280+
// forwarding logic above doesn't apply to them since they are also implemented in the
281+
// UIScrollView class, the base class.
282+
283+
- (BOOL)accessibilityActivate {
284+
return [_semanticsObject accessibilityActivate];
285+
}
286+
287+
- (void)accessibilityIncrement {
288+
[_semanticsObject accessibilityIncrement];
289+
}
290+
291+
- (void)accessibilityDecrement {
292+
[_semanticsObject accessibilityDecrement];
293+
}
294+
295+
- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction {
296+
return [_semanticsObject accessibilityScroll:direction];
297+
}
298+
299+
- (BOOL)accessibilityPerformEscape {
300+
return [_semanticsObject accessibilityPerformEscape];
301+
}
302+
303+
- (void)accessibilityElementDidBecomeFocused {
304+
[_semanticsObject accessibilityElementDidBecomeFocused];
305+
}
306+
307+
- (void)accessibilityElementDidLoseFocus {
308+
[_semanticsObject accessibilityElementDidLoseFocus];
309+
}
310+
@end // FlutterScrollableSemanticsObject
311+
91312
@implementation FlutterCustomAccessibilityAction {
92313
}
93314
@end
@@ -174,6 +395,9 @@ - (void)setSemanticsNode:(const flutter::SemanticsNode*)node {
174395
_node = *node;
175396
}
176397

398+
- (void)accessibilityBridgeDidFinishUpdate { /* Do nothing by default */
399+
}
400+
177401
/**
178402
* Whether calling `setSemanticsNode:` with `node` would cause a layout change.
179403
*/
@@ -398,27 +622,9 @@ - (CGRect)accessibilityFrame {
398622
}
399623

400624
- (CGRect)globalRect {
401-
SkM44 globalTransform = [self node].transform;
402-
for (SemanticsObject* parent = [self parent]; parent; parent = parent.parent) {
403-
globalTransform = parent.node.transform * globalTransform;
404-
}
405-
406-
SkPoint quad[4];
407-
[self node].rect.toQuad(quad);
408-
for (auto& point : quad) {
409-
SkV4 vector = globalTransform.map(point.x(), point.y(), 0, 1);
410-
point.set(vector.x / vector.w, vector.y / vector.w);
411-
}
412-
SkRect rect;
413-
rect.setBounds(quad, 4);
414-
415-
// `rect` is in the physical pixel coordinate system. iOS expects the accessibility frame in
416-
// the logical pixel coordinate system. Therefore, we divide by the `scale` (pixel ratio) to
417-
// convert.
418-
CGFloat scale = [[[self bridge]->view() window] screen].scale;
419-
auto result =
420-
CGRectMake(rect.x() / scale, rect.y() / scale, rect.width() / scale, rect.height() / scale);
421-
return UIAccessibilityConvertFrameToScreenCoordinates(result, [self bridge]->view());
625+
const SkRect& rect = [self node].rect;
626+
CGRect localRect = CGRectMake(rect.x(), rect.y(), rect.width(), rect.height());
627+
return ConvertRectToGlobal(self, localRect);
422628
}
423629

424630
#pragma mark - UIAccessibilityElement protocol

0 commit comments

Comments
 (0)