Skip to content

Commit 8a1d4c0

Browse files
[iOS] Full keyboard access scrolling (flutter/engine#56606)
This PR adds basic FKA scrolling support: when the iOS focus (the focus state is maintained separately from the framework focus, see the previous PR) switches to an item in a scrollable container that is too close to the edge of the viewport, the container will scroll to make sure the next item is visible. Previous PR for context: flutter/engine#55964 https://github.com/user-attachments/assets/84ae5153-f955-4d23-9901-ce942c0e98ac ### Why the UIScrollView subclass in the focus hierarchy The iOS focus system does not provide an API that allows apps to notify it of focus highlight changes. So if we were to keep using the transforms sent by the framework as-is and not introducing any UIViews in the focus hierarchy, the focus highlight will be positioned at the wrong location after scrolling (via FKA or via framework). That does not seem to be part of the public API and the focus system seems to only know how to properly highlight focusable UIViews. ### Things that currently may not work 1. Nested scroll views (have not tried to verify) The `UIScrollView`s are always subviews of the `FlutterView`. If there are nested scrollables the focus system may not be able to properly determine the focus hierarchy (in theory the iOS focus system should never depend on `UIView.parentView` but I haven't tried to verify that). 2. If the next item is too far below the bottom of the screen and there is a tab bar with focusable items, the focus will be transferred to tab bar instead of the next item in the list Video demo (as you can see the scrolling is really finicky): https://github.com/user-attachments/assets/51c2bfe4-d7b3-4614-aa49-4256214f8978 I've tried doing the same thing using a `UITableView` with similar configurations but it seems to have the same problem. I'll try to dig a bit deeper into this and see if there's a workaround. [C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
1 parent 448ac01 commit 8a1d4c0

File tree

15 files changed

+295
-25
lines changed

15 files changed

+295
-25
lines changed

engine/src/flutter/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')

engine/src/flutter/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 =

engine/src/flutter/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,

engine/src/flutter/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) {

engine/src/flutter/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

engine/src/flutter/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;

engine/src/flutter/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

engine/src/flutter/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

engine/src/flutter/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
/**

engine/src/flutter/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)