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

Commit b04f507

Browse files
Revert "Reverts "[iOS] Full keyboard access scrolling (#56606)" (#56802)"
This reverts commit 50174be.
1 parent cd6a35d commit b04f507

15 files changed

+295
-25
lines changed

lib/ui/semantics.dart

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class SemanticsAction {
4545
static const int _kMoveCursorBackwardByWordIndex = 1 << 20;
4646
static const int _kSetTextIndex = 1 << 21;
4747
static const int _kFocusIndex = 1 << 22;
48+
static const int _kScrollToOffsetIndex = 1 << 23;
4849
// READ THIS: if you add an action here, you MUST update the
4950
// numSemanticsActions value in testing/dart/semantics_test.dart and
5051
// lib/web_ui/test/engine/semantics/semantics_api_test.dart, or tests
@@ -86,6 +87,17 @@ class SemanticsAction {
8687
/// scrollable.
8788
static const SemanticsAction scrollDown = SemanticsAction._(_kScrollDownIndex, 'scrollDown');
8889

90+
/// A request to scroll the scrollable container to a given scroll offset.
91+
///
92+
/// The payload of this [SemanticsAction] is a flutter-standard-encoded
93+
/// [Float64List] of length 2 containing the target horizontal and vertical
94+
/// offsets (in logical pixels) the receiving scrollable container should
95+
/// scroll to.
96+
///
97+
/// This action is used by iOS Full Keyboard Access to reveal contents that
98+
/// are currently not visible in the viewport.
99+
static const SemanticsAction scrollToOffset = SemanticsAction._(_kScrollToOffsetIndex, 'scrollToOffset');
100+
89101
/// A request to increase the value represented by the semantics node.
90102
///
91103
/// For example, this action might be recognized by a slider control.
@@ -265,6 +277,7 @@ class SemanticsAction {
265277
_kScrollRightIndex: scrollRight,
266278
_kScrollUpIndex: scrollUp,
267279
_kScrollDownIndex: scrollDown,
280+
_kScrollToOffsetIndex: scrollToOffset,
268281
_kIncreaseIndex: increase,
269282
_kDecreaseIndex: decrease,
270283
_kShowOnScreenIndex: showOnScreen,
@@ -764,7 +777,7 @@ base class LocaleStringAttribute extends StringAttribute {
764777
_initLocaleStringAttribute(this, range.start, range.end, locale.toLanguageTag());
765778
}
766779

767-
/// The lanuage of this attribute.
780+
/// The language of this attribute.
768781
final Locale locale;
769782

770783
@Native<Void Function(Handle, Int32, Int32, Handle)>(symbol: 'NativeStringAttribute::initLocaleStringAttribute')

lib/ui/semantics/semantics_node.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ enum class SemanticsAction : int32_t {
4343
kMoveCursorBackwardByWord = 1 << 20,
4444
kSetText = 1 << 21,
4545
kFocus = 1 << 22,
46+
kScrollToOffset = 1 << 23,
4647
};
4748

4849
const int kVerticalScrollSemanticsActions =

lib/web_ui/lib/semantics.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ class SemanticsAction {
3333
static const int _kMoveCursorBackwardByWordIndex = 1 << 20;
3434
static const int _kSetTextIndex = 1 << 21;
3535
static const int _kFocusIndex = 1 << 22;
36+
static const int _kScrollToOffsetIndex = 1 << 23;
3637

3738
static const SemanticsAction tap = SemanticsAction._(_kTapIndex, 'tap');
3839
static const SemanticsAction longPress = SemanticsAction._(_kLongPressIndex, 'longPress');
3940
static const SemanticsAction scrollLeft = SemanticsAction._(_kScrollLeftIndex, 'scrollLeft');
4041
static const SemanticsAction scrollRight = SemanticsAction._(_kScrollRightIndex, 'scrollRight');
4142
static const SemanticsAction scrollUp = SemanticsAction._(_kScrollUpIndex, 'scrollUp');
4243
static const SemanticsAction scrollDown = SemanticsAction._(_kScrollDownIndex, 'scrollDown');
44+
static const SemanticsAction scrollToOffset = SemanticsAction._(_kScrollToOffsetIndex, 'scrollToOffset');
4345
static const SemanticsAction increase = SemanticsAction._(_kIncreaseIndex, 'increase');
4446
static const SemanticsAction decrease = SemanticsAction._(_kDecreaseIndex, 'decrease');
4547
static const SemanticsAction showOnScreen = SemanticsAction._(_kShowOnScreenIndex, 'showOnScreen');
@@ -65,6 +67,7 @@ class SemanticsAction {
6567
_kScrollRightIndex: scrollRight,
6668
_kScrollUpIndex: scrollUp,
6769
_kScrollDownIndex: scrollDown,
70+
_kScrollToOffsetIndex: scrollToOffset,
6871
_kIncreaseIndex: increase,
6972
_kDecreaseIndex: decrease,
7073
_kShowOnScreenIndex: showOnScreen,

lib/web_ui/test/engine/semantics/semantics_api_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ void testMain() {
2929
});
3030

3131
// This must match the number of actions in lib/ui/semantics.dart
32-
const int numSemanticsActions = 23;
32+
const int numSemanticsActions = 24;
3333
test('SemanticsAction.values refers to all actions.', () async {
3434
expect(SemanticsAction.values.length, equals(numSemanticsActions));
3535
for (int index = 0; index < numSemanticsActions; ++index) {

shell/platform/android/io/flutter/view/AccessibilityBridge.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2120,7 +2120,8 @@ public enum Action {
21202120
MOVE_CURSOR_FORWARD_BY_WORD(1 << 19),
21212121
MOVE_CURSOR_BACKWARD_BY_WORD(1 << 20),
21222122
SET_TEXT(1 << 21),
2123-
FOCUS(1 << 22);
2123+
FOCUS(1 << 22),
2124+
SCROLL_TO_OFFSET(1 << 23);
21242125

21252126
public final int value;
21262127

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,30 @@ NS_ASSUME_NONNULL_BEGIN
1818
* sends all of selector calls from accessibility services to the
1919
* owner SemanticsObject.
2020
*/
21-
@interface FlutterSemanticsScrollView : UIScrollView
21+
@interface FlutterSemanticsScrollView : UIScrollView <UIScrollViewDelegate>
2222

2323
@property(nonatomic, weak, nullable) SemanticsObject* semanticsObject;
2424

25+
/// Whether this scroll view's content offset is actively being updated by UIKit
26+
/// or other the system services.
27+
///
28+
/// This flag is set by the `FlutterSemanticsScrollView` itself, typically in
29+
/// one of the `UIScrollViewDelegate` methods.
30+
///
31+
/// When this flag is true, the `SemanticsObject` implementation ignores all
32+
/// content offset updates coming from the Flutter framework, to prevent
33+
/// potential feedback loops (especially when the framework is only echoing
34+
/// the new content offset back to this scroll view).
35+
///
36+
/// For example, to scroll a scrollable container with iOS full keyboard access,
37+
/// the iOS focus system uses a display link to scroll the container to the
38+
/// desired offset animatedly. If the user changes the scroll offset during the
39+
/// animation, the display link will be invalidated and the scrolling animation
40+
/// will be interrupted. For simplicity, content offset updates coming from the
41+
/// framework will be ignored in the relatively short animation duration (~1s),
42+
/// allowing the scrolling animation to finish.
43+
@property(nonatomic, readonly) BOOL isDoingSystemScrolling;
44+
2545
- (instancetype)init NS_UNAVAILABLE;
2646
- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE;
2747
- (instancetype)initWithCoder:(NSCoder*)coder NS_UNAVAILABLE;

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ - (instancetype)initWithSemanticsObject:(SemanticsObject*)semanticsObject {
1515
self = [super initWithFrame:CGRectZero];
1616
if (self) {
1717
_semanticsObject = semanticsObject;
18+
_isDoingSystemScrolling = NO;
19+
self.delegate = self;
1820
}
1921
return self;
2022
}
@@ -105,4 +107,14 @@ - (NSInteger)accessibilityElementCount {
105107
return self.semanticsObject.children.count;
106108
}
107109

110+
- (void)scrollViewWillEndDragging:(UIScrollView*)scrollView
111+
withVelocity:(CGPoint)velocity
112+
targetContentOffset:(inout CGPoint*)targetContentOffset {
113+
_isDoingSystemScrolling = YES;
114+
}
115+
116+
- (void)scrollViewDidEndDecelerating:(UIScrollView*)scrollView {
117+
_isDoingSystemScrolling = NO;
118+
}
119+
108120
@end

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

Lines changed: 145 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
// found in the LICENSE file.
44

55
#import "SemanticsObject.h"
6+
#include "flutter/lib/ui/semantics/semantics_node.h"
7+
#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterCodecs.h"
68
#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"
9+
#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h"
710

811
FLUTTER_ASSERT_ARC
912

@@ -27,10 +30,19 @@
2730
// translated to calls such as -[NSObject accessibilityActivate]), while most
2831
// other key events are dispatched to the framework.
2932
@interface SemanticsObject (UIFocusSystem) <UIFocusItem, UIFocusItemContainer>
33+
/// The `UIFocusItem` that represents this SemanticsObject.
34+
///
35+
/// For regular `SemanticsObject`s, this method returns `self`,
36+
/// for `FlutterScrollableSemanticsObject`s, this method returns its scroll view.
37+
- (id<UIFocusItem>)focusItem;
3038
@end
3139

3240
@implementation SemanticsObject (UIFocusSystem)
3341

42+
- (id<UIFocusItem>)focusItem {
43+
return self;
44+
}
45+
3446
#pragma mark - UIFocusEnvironment Conformance
3547

3648
- (void)setNeedsFocusUpdate {
@@ -49,7 +61,7 @@ - (void)didUpdateFocusInContext:(UIFocusUpdateContext*)context
4961

5062
- (id<UIFocusEnvironment>)parentFocusEnvironment {
5163
// The root SemanticsObject node's parent is the FlutterView.
52-
return self.parent ?: self.bridge->view();
64+
return self.parent.focusItem ?: self.bridge->view();
5365
}
5466

5567
- (NSArray<id<UIFocusEnvironment>>*)preferredFocusEnvironments {
@@ -71,8 +83,57 @@ - (BOOL)canBecomeFocused {
7183
return self.node.HasAction(flutter::SemanticsAction::kTap);
7284
}
7385

86+
// The frame is described in the `coordinateSpace` of the
87+
// `parentFocusEnvironment` (all `parentFocusEnvironment`s are `UIFocusItem`s).
88+
//
89+
// See also the `coordinateSpace` implementation.
90+
// TODO(LongCatIsLooong): use CoreGraphics types.
7491
- (CGRect)frame {
75-
return self.accessibilityFrame;
92+
SkPoint quad[4] = {SkPoint::Make(self.node.rect.left(), self.node.rect.top()),
93+
SkPoint::Make(self.node.rect.left(), self.node.rect.bottom()),
94+
SkPoint::Make(self.node.rect.right(), self.node.rect.top()),
95+
SkPoint::Make(self.node.rect.right(), self.node.rect.bottom())};
96+
97+
SkM44 transform = self.node.transform;
98+
FlutterSemanticsScrollView* scrollView;
99+
for (SemanticsObject* ancestor = self.parent; ancestor; ancestor = ancestor.parent) {
100+
if ([ancestor isKindOfClass:[FlutterScrollableSemanticsObject class]]) {
101+
scrollView = ((FlutterScrollableSemanticsObject*)ancestor).scrollView;
102+
break;
103+
}
104+
transform = ancestor.node.transform * transform;
105+
}
106+
107+
for (auto& vertex : quad) {
108+
SkV4 vector = transform.map(vertex.x(), vertex.y(), 0, 1);
109+
vertex = SkPoint::Make(vector.x / vector.w, vector.y / vector.w);
110+
}
111+
112+
SkRect rect;
113+
rect.setBounds(quad, 4);
114+
// If this UIFocusItemContainer's coordinateSpace is a UIScrollView, offset
115+
// the rect by `contentOffset` because the contentOffset translation is
116+
// incorporated into the paint transform at different node depth in UIKit
117+
// and Flutter. In Flutter, the translation is added to the cells
118+
// while in UIKit the viewport's bounds is manipulated (IOW, each cell's frame
119+
// in the UIScrollView coordinateSpace does not change when the UIScrollView
120+
// scrolls).
121+
CGRect unscaledRect =
122+
CGRectMake(rect.x() + scrollView.bounds.origin.x, rect.y() + scrollView.bounds.origin.y,
123+
rect.width(), rect.height());
124+
if (scrollView) {
125+
return unscaledRect;
126+
}
127+
// `rect` could be in physical pixels since the root RenderObject ("RenderView")
128+
// applies a transform that turns logical pixels to physical pixels. Undo the
129+
// transform by dividing the coordinates by the screen's scale factor, if this
130+
// UIFocusItem's reported `coordinateSpace` is the root view (which means this
131+
// UIFocusItem is not inside of a scroll view).
132+
//
133+
// Screen can be nil if the FlutterView is covered by another native view.
134+
CGFloat scale = (self.bridge->view().window.screen ?: UIScreen.mainScreen).scale;
135+
return CGRectMake(unscaledRect.origin.x / scale, unscaledRect.origin.y / scale,
136+
unscaledRect.size.width / scale, unscaledRect.size.height / scale);
76137
}
77138

78139
#pragma mark - UIFocusItemContainer Conformance
@@ -87,16 +148,94 @@ - (CGRect)frame {
87148
//
88149
// This method is only supposed to return items within the given
89150
// rect but returning everything in the subtree seems to work fine.
90-
NSMutableArray<SemanticsObject*>* reversedItems =
151+
NSMutableArray<id<UIFocusItem>>* reversedItems =
91152
[[NSMutableArray alloc] initWithCapacity:self.childrenInHitTestOrder.count];
92153
for (NSUInteger i = 0; i < self.childrenInHitTestOrder.count; ++i) {
93-
[reversedItems
94-
addObject:self.childrenInHitTestOrder[self.childrenInHitTestOrder.count - 1 - i]];
154+
SemanticsObject* child = self.childrenInHitTestOrder[self.childrenInHitTestOrder.count - 1 - i];
155+
[reversedItems addObject:child.focusItem];
95156
}
96157
return reversedItems;
97158
}
98159

99160
- (id<UICoordinateSpace>)coordinateSpace {
100-
return self.bridge->view();
161+
// A regular SemanticsObject uses the same coordinate space as its parent.
162+
return self.parent.coordinateSpace ?: self.bridge->view();
163+
}
164+
165+
@end
166+
167+
/// Scrollable containers interact with the iOS focus engine using the
168+
/// `UIFocusItemScrollableContainer` protocol. The said protocol (and other focus-related protocols)
169+
/// does not provide means to inform the focus system of layout changes. In order for the focus
170+
/// highlight to update properly as the scroll view scrolls, this implementation incorporates a
171+
/// UIScrollView into the focus hierarchy to workaround the highlight update problem.
172+
///
173+
/// As a result, in the current implementation only scrollable containers and the root node
174+
/// establish their own `coordinateSpace`s. All other `UIFocusItemContainter`s use the same
175+
/// `coordinateSpace` as the containing UIScrollView, or the root `FlutterView`, whichever is
176+
/// closer.
177+
///
178+
/// See also the `frame` method implementation.
179+
#pragma mark - Scrolling
180+
181+
@interface FlutterScrollableSemanticsObject (CoordinateSpace)
182+
@end
183+
184+
@implementation FlutterScrollableSemanticsObject (CoordinateSpace)
185+
- (id<UICoordinateSpace>)coordinateSpace {
186+
// A scrollable SemanticsObject uses the same coordinate space as the scroll view.
187+
// This may not work very well in nested scroll views.
188+
return self.scrollView;
189+
}
190+
191+
- (id<UIFocusItem>)focusItem {
192+
return self.scrollView;
193+
}
194+
195+
@end
196+
197+
@interface FlutterSemanticsScrollView (UIFocusItemScrollableContainer) <
198+
UIFocusItemScrollableContainer>
199+
@end
200+
201+
@implementation FlutterSemanticsScrollView (UIFocusItemScrollableContainer)
202+
203+
#pragma mark - FlutterSemanticsScrollView UIFocusItemScrollableContainer Conformance
204+
205+
- (CGSize)visibleSize {
206+
return self.frame.size;
207+
}
208+
209+
- (void)setContentOffset:(CGPoint)contentOffset {
210+
[super setContentOffset:contentOffset];
211+
// Do no send flutter::SemanticsAction::kScrollToOffset if it's triggered
212+
// by a framework update.
213+
if (![self.semanticsObject isAccessibilityBridgeAlive] || !self.isDoingSystemScrolling) {
214+
return;
215+
}
216+
217+
double offset[2] = {contentOffset.x, contentOffset.y};
218+
FlutterStandardTypedData* offsetData = [FlutterStandardTypedData
219+
typedDataWithFloat64:[NSData dataWithBytes:&offset length:sizeof(offset)]];
220+
NSData* encoded = [[FlutterStandardMessageCodec sharedInstance] encode:offsetData];
221+
self.semanticsObject.bridge->DispatchSemanticsAction(
222+
self.semanticsObject.uid, flutter::SemanticsAction::kScrollToOffset,
223+
fml::MallocMapping::Copy(encoded.bytes, encoded.length));
224+
}
225+
226+
- (BOOL)canBecomeFocused {
227+
return NO;
228+
}
229+
230+
- (id<UIFocusEnvironment>)parentFocusEnvironment {
231+
return self.semanticsObject.parentFocusEnvironment;
232+
}
233+
234+
- (NSArray<id<UIFocusEnvironment>>*)preferredFocusEnvironments {
235+
return nil;
236+
}
237+
238+
- (NSArray<id<UIFocusItem>>*)focusItemsInRect:(CGRect)rect {
239+
return [self.semanticsObject focusItemsInRect:rect];
101240
}
102241
@end

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include "flutter/fml/macros.h"
1111
#include "flutter/fml/memory/weak_ptr.h"
1212
#include "flutter/lib/ui/semantics/semantics_node.h"
13+
#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h"
1314
#import "flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_ios.h"
1415

1516
constexpr int32_t kRootNodeId = 0;
@@ -186,7 +187,7 @@ constexpr float kScrollExtentMaxForInf = 1000;
186187
/// The semantics object for scrollable. This class creates an UIScrollView to interact with the
187188
/// iOS.
188189
@interface FlutterScrollableSemanticsObject : SemanticsObject
189-
190+
@property(nonatomic, readonly) FlutterSemanticsScrollView* scrollView;
190191
@end
191192

192193
/**

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ - (instancetype)initWithBridge:(fml::WeakPtr<flutter::AccessibilityBridgeIos>)br
154154
_scrollView = [[FlutterSemanticsScrollView alloc] initWithSemanticsObject:self];
155155
[_scrollView setShowsHorizontalScrollIndicator:NO];
156156
[_scrollView setShowsVerticalScrollIndicator:NO];
157+
[_scrollView setContentInset:UIEdgeInsetsZero];
158+
[_scrollView setContentInsetAdjustmentBehavior:UIScrollViewContentInsetAdjustmentNever];
157159
[self.bridge->view() addSubview:_scrollView];
158160
}
159161
return self;
@@ -174,7 +176,10 @@ - (void)accessibilityBridgeDidFinishUpdate {
174176
// contentOffset is 0.0, only the scroll down action is available.
175177
self.scrollView.frame = self.accessibilityFrame;
176178
self.scrollView.contentSize = [self contentSizeInternal];
177-
[self.scrollView setContentOffset:[self contentOffsetInternal] animated:NO];
179+
// See the documentation on `isDoingSystemScrolling`.
180+
if (!self.scrollView.isDoingSystemScrolling) {
181+
[self.scrollView setContentOffset:self.contentOffsetInternal animated:NO];
182+
}
178183
}
179184

180185
- (id)nativeAccessibility {

0 commit comments

Comments
 (0)